├── .env.template ├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── TODO.org ├── assets ├── .babelrc ├── css │ ├── admin.css │ ├── app.scss │ ├── bulma.min.css │ ├── global │ │ ├── bulma-select2.scss │ │ ├── drop-area.css │ │ ├── hacks.css │ │ └── table.css │ └── select2.min.css ├── js │ ├── app.js │ ├── navbar_burger.js │ ├── phx-hooks.js │ ├── select2.min.js │ ├── socket.js │ └── xwiper.js ├── package-lock.json ├── package.json ├── static │ ├── ViewerJS │ │ ├── compatibility.js │ │ ├── example.local.css │ │ ├── images │ │ │ ├── kogmbh.png │ │ │ ├── nlnet.png │ │ │ ├── texture.png │ │ │ ├── toolbarButton-download.png │ │ │ ├── toolbarButton-fullscreen.png │ │ │ ├── toolbarButton-menuArrows.png │ │ │ ├── toolbarButton-pageDown.png │ │ │ ├── toolbarButton-pageUp.png │ │ │ ├── toolbarButton-presentation.png │ │ │ ├── toolbarButton-zoomIn.png │ │ │ └── toolbarButton-zoomOut.png │ │ ├── index.html │ │ ├── pdf.js │ │ ├── pdf.worker.js │ │ ├── pdfjsversion.js │ │ ├── text_layer_builder.js │ │ ├── ui_utils.js │ │ └── webodf.js │ ├── favicon.ico │ └── robots.txt └── webpack.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── releases.exs └── test.exs ├── docker-compose.yml ├── docker-data ├── Dockerfile.certbot ├── Dockerfile.nginx-proxy ├── Dockerfile.phoenix ├── certbot-entrypoint.sh ├── default.conf.template ├── nginx-proxy-reloading-script.sh └── phoenix-entrypoint.sh ├── docs └── old-database-uml.png ├── initial-setup-env.exs ├── lib ├── deer_cache │ ├── records_counts_cache.ex │ ├── subscription_storage_cache.ex │ └── supervisor.ex ├── deer_storage.ex ├── deer_storage │ ├── application.ex │ ├── csv_importer.ex │ ├── db_helpers │ │ ├── compose_search_query.ex │ │ └── deer_records_search.ex │ ├── deer_records.ex │ ├── deer_records │ │ ├── deer_field.ex │ │ ├── deer_file.ex │ │ └── deer_record.ex │ ├── deer_tables_examples.ex │ ├── delete_outdated_shared_records_and_files_every_24h.ex │ ├── ecto_helpers.ex │ ├── feature_flags.ex │ ├── release.ex │ ├── repo.ex │ ├── services │ │ ├── calculate_deer_storage.ex │ │ └── upload_deer_file.ex │ ├── shared_files.ex │ ├── shared_files │ │ └── shared_file.ex │ ├── shared_records.ex │ ├── shared_records │ │ └── shared_record.ex │ ├── subscriptions.ex │ ├── subscriptions │ │ ├── _helpers.ex │ │ ├── deer_column.ex │ │ ├── deer_table.ex │ │ └── subscription.ex │ ├── user_available_subscription_links │ │ └── user_available_subscription_link.ex │ ├── users.ex │ └── users │ │ ├── user.ex │ │ └── user_session_utils.ex ├── deer_storage_web.ex ├── deer_storage_web │ ├── controllers │ │ ├── _confirmation_helpers.ex │ │ ├── _feature_flags_helpers.ex │ │ ├── _file_helpers.ex │ │ ├── _subscription_helpers.ex │ │ ├── _user_helpers.ex │ │ ├── admin │ │ │ ├── subscription_controller.ex │ │ │ ├── user_controller.ex │ │ │ └── user_subscription_link_controller.ex │ │ ├── change_language_controller.ex │ │ ├── confirmation_controller.ex │ │ ├── deer_files_controller.ex │ │ ├── invitation_controller.ex │ │ ├── page_controller.ex │ │ ├── registration_controller.ex │ │ ├── reset_password_controller.ex │ │ ├── session_controller.ex │ │ ├── shared_record_files_controller.ex │ │ └── user_controller.ex │ ├── date_helpers.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ │ ├── admin_dashboard_live │ │ │ └── index.ex │ │ ├── deer_dashboard_live │ │ │ ├── deer_table_edit_component.ex │ │ │ ├── deer_table_show_component.ex │ │ │ └── index.ex │ │ ├── deer_records_live │ │ │ ├── index.ex │ │ │ ├── modal │ │ │ │ ├── connect_record_component.ex │ │ │ │ ├── created_shared_record_component.ex │ │ │ │ ├── edit_component.ex │ │ │ │ ├── new_component.ex │ │ │ │ ├── preview_file_component.ex │ │ │ │ └── uploading_file_component.ex │ │ │ ├── show_component.ex │ │ │ └── socket_assigns │ │ │ │ ├── _helpers.ex │ │ │ │ ├── connecting_records.ex │ │ │ │ ├── editing_record.ex │ │ │ │ ├── new_connected_record.ex │ │ │ │ ├── new_record.ex │ │ │ │ ├── opened_records.ex │ │ │ │ ├── records.ex │ │ │ │ ├── subscription.ex │ │ │ │ └── uploading_files.ex │ │ ├── shared_records_live │ │ │ └── show.ex │ │ └── subscription_navigation_live.ex │ ├── live_helpers.ex │ ├── metrics_storage.ex │ ├── plugs │ │ ├── auth_error_handler.ex │ │ ├── ensure_role_plug.ex │ │ ├── get_current_subscription_plug.ex │ │ └── locale_plug.ex │ ├── pow_mailer.ex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ │ ├── admin │ │ │ ├── dashboard │ │ │ │ └── index.html.leex │ │ │ ├── subscription │ │ │ │ ├── _form.html.eex │ │ │ │ ├── _header.html.eex │ │ │ │ ├── _index_pagination.html.eex │ │ │ │ ├── edit.html.eex │ │ │ │ ├── index.html.eex │ │ │ │ ├── new.html.eex │ │ │ │ └── show.html.eex │ │ │ └── user │ │ │ │ ├── _header.html.eex │ │ │ │ ├── _index_pagination.html.eex │ │ │ │ ├── edit.html.eex │ │ │ │ ├── form.html.eex │ │ │ │ ├── index.html.eex │ │ │ │ ├── new.html.eex │ │ │ │ └── show.html.eex │ │ ├── deer_dashboard │ │ │ ├── _editable_title.html.leex │ │ │ ├── _import_csv.html.leex │ │ │ ├── editable_deer_tables.html.leex │ │ │ └── examples_index.html.leex │ │ ├── deer_record │ │ │ ├── _editable_prepared_fields.html.leex │ │ │ ├── index.html.leex │ │ │ ├── preview_modal_document.html.leex │ │ │ ├── preview_modal_image.html.leex │ │ │ ├── preview_modal_video.html.leex │ │ │ └── show_external.html.leex │ │ ├── invitation │ │ │ ├── edit.html.eex │ │ │ └── new.html.eex │ │ ├── layout │ │ │ ├── app.html.leex │ │ │ ├── navigation_admin.html.eex │ │ │ ├── navigation_guest.html.eex │ │ │ ├── navigation_user.html.eex │ │ │ ├── root.html.leex │ │ │ └── without_navigation.html.leex │ │ ├── pagination │ │ │ └── simple.html.eex │ │ ├── pow_email_confirmation │ │ │ └── mailer │ │ │ │ ├── email_confirmation.html.eex │ │ │ │ └── email_confirmation.text.eex │ │ ├── pow_invitation │ │ │ └── mailer │ │ │ │ ├── invitation.html.eex │ │ │ │ └── invitation.text.eex │ │ ├── pow_reset_password │ │ │ └── mailer │ │ │ │ ├── reset_password.html.eex │ │ │ │ └── reset_password.text.eex │ │ ├── registration │ │ │ ├── current_subscription.html.eex │ │ │ ├── edit.html.eex │ │ │ ├── new.html.eex │ │ │ └── user_fields.html.eex │ │ ├── reset_password │ │ │ ├── edit.html.eex │ │ │ └── new.html.eex │ │ ├── session │ │ │ └── new.html.eex │ │ └── user │ │ │ └── index.html.eex │ └── views │ │ ├── admin │ │ ├── dashboard_view.ex │ │ ├── subscription_view.ex │ │ └── user_view.ex │ │ ├── deer_dashboard_view.ex │ │ ├── deer_record_view.ex │ │ ├── error_helpers.ex │ │ ├── error_view.ex │ │ ├── invitation_view.ex │ │ ├── layout_view.ex │ │ ├── page_view.ex │ │ ├── pagination_view.ex │ │ ├── pow_email_confirmation │ │ └── mailer_view.ex │ │ ├── pow_invitation │ │ └── mailer_view.ex │ │ ├── pow_reset_password │ │ └── mailer_view.ex │ │ ├── registration_view.ex │ │ ├── reset_password_view.ex │ │ ├── session_view.ex │ │ └── user_view.ex └── tasks │ ├── deer_storage.add_fakered_cars_to_subscription.ex │ ├── deer_storage.add_fakered_people_to_subscription.ex │ └── deer_storage.create_user.ex ├── mix.exs ├── mix.lock ├── priv ├── .well-known │ └── .keep ├── gettext │ ├── default.pot │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── default.po │ │ │ ├── errors.po │ │ │ └── people.po │ ├── errors.pot │ ├── people.pot │ └── pl │ │ └── LC_MESSAGES │ │ ├── default.po │ │ ├── errors.po │ │ └── people.po ├── mnesia │ └── .keep └── repo │ ├── migrations │ ├── .formatter.exs │ ├── 20191101143314_create_users.exs │ ├── 20191102143340_alter_users_with_locale.exs │ ├── 20191102181046_alter_users_with_displayed_name.exs │ ├── 20191104095235_alter_users_with_role.exs │ ├── 20191105215425_alter_users_with_admin_notes.exs │ ├── 20191108160955_create_subscriptions.exs │ ├── 20191118151954_rename_displayed_name_to_name_in_users.exs │ ├── 20191119140722_alter_subscriptions_with_admin_notes.exs │ ├── 20191120095545_alter_subscriptions_with_time_zone.exs │ ├── 20200307083533_create_extension_unaccent.exs │ ├── 20200312044234_add_pow_email_confirmation_to_users.exs │ ├── 20200315054554_add_pow_invitation_to_users.exs │ ├── 20200320084407_create_user_available_subscription_links.exs │ ├── 20200502074234_rename_users_subscription_id_to_last_used_subscription_id.exs │ ├── 20200506181625_add_deer_tables_to_subscriptions.exs │ ├── 20200507065258_create_deer_records.exs │ ├── 20200829081750_add_deer_files_to_deer_records.exs │ ├── 20200902052439_add_storage_limit_to_subscriptions.exs │ ├── 20200904173814_add_permission_to_manage_users_to_user_available_subscription_links.exs │ ├── 20200906082412_add_deer_file_limit_to_subscription.exs │ ├── 20200906130111_add_deer_records_per_table_limit_to_subscription.exs │ ├── 20200906130131_add_deer_tables_limit_to_subscription.exs │ ├── 20200909034056_add_deer_columns_per_table_limit_to_subscription.exs │ ├── 20201006132706_enable_pg_crypto.exs │ ├── 20201006141242_create_shared_records.exs │ ├── 20201016074452_add_connected_deer_records_ids_to_deer_records.exs │ ├── 20201115072646_add_editing_bool_to_deer_shared_records.exs │ ├── 20201230064915_create_shared_files.exs │ ├── 20210126174050_add_notes_to_deer_records.exs │ └── 20210222120410_generate_files_mimetypes_and_update_records.exs │ └── seeds.exs └── test ├── csv_examples └── polish_people.csv ├── deer_cache └── records_counts_cache_test.exs ├── deer_storage ├── csv_importer_test.exs ├── db_helpers │ └── deer_records_search_test.exs ├── deer_records │ └── deer_records_test.exs ├── shared_records │ └── shared_records_test.exs ├── subscription_test.exs ├── subscriptions │ ├── deer_table_test.exs │ └── subscription_deer_tables_test.exs └── users │ └── users_test.exs ├── deer_storage_web ├── controllers │ ├── admin │ │ ├── subscriptions_controller_test.exs │ │ └── user_controller_test.exs │ ├── invitation_controller_test.exs │ ├── page_controller_test.exs │ ├── registration_controller_test.exs │ ├── reset_password_controller_test.exs │ └── session_controller_test.exs ├── plugs │ └── locale_plug_test.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex ├── deer_fixtures.ex ├── ets_cache_mock.ex ├── fixtures.ex ├── session_helpers.ex └── test_pow_message_verifier.ex └── test_helper.exs /.env.template: -------------------------------------------------------------------------------- 1 | APP_HTTP_PORT= 2 | APP_HTTPS_PORT= 3 | APP_HOST= 4 | SECRET_KEY_BASE= 5 | PGUSER= 6 | PGPASSWORD= 7 | PGDATABASE= 8 | PGHOST=db 9 | PGPORT=5432 10 | 11 | # you must recompile DeerStorage/recreate container after changing Mailgun credentials 12 | POW_MAILGUN_BASE_URI= 13 | POW_MAILGUN_DOMAIN= 14 | POW_MAILGUN_API_KEY= 15 | 16 | LETSENCRYPT_ENABLED=0 17 | LETSENCRYPT_EMAIL= 18 | LETSENCRYPT_STAGING=0 # Set to 1 if you're testing your setup to avoid hitting request limits 19 | 20 | FEATURE_REGISTRATION=1 21 | FEATURE_AUTOCONFIRM_AND_PROMOTE_FIRST_USER_TO_ADMIN=1 22 | 23 | NEW_SUBSCRIPTION_DAYS_TO_EXPIRE=90 24 | NEW_SUBSCRIPTION_RECORDS_PER_TABLE_LIMIT=20000 25 | NEW_SUBSCRIPTION_COLUMNS_PER_TABLE_LIMIT=20 26 | NEW_SUBSCRIPTION_TABLES_LIMIT=10 27 | NEW_SUBSCRIPTION_FILES_COUNT_LIMIT=2000 28 | NEW_SUBSCRIPTION_STORAGE_LIMIT_IN_KILOBYTES=1024000 29 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | deer_storage-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | 36 | /.elixir_ls/ 37 | 38 | /uploaded_files/ 39 | /Mnesia.* 40 | .env 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DeerStorage 2 | 3 | Read more about this project here: http://gladecki.pl/2021/05/16/deerstorage/ 4 | 5 | ## Installation 6 | 7 | 1. Install docker-compose 8 | https://docs.docker.com/compose/install/ 9 | 2. Clone the repository 10 | 11 | ``` sh 12 | git clone https://github.com/intpl/deer_storage.git 13 | cd deer_storage 14 | ``` 15 | 2. Run initial script to generate `.env` file. Answer all of the questions. If you are launching it locally, just press [ENTER] until the script exits to stick with defaults (localhost, Let's Encrypt disabled, 1GB per "database" etc.) 16 | ``` sh 17 | elixir init-setup-env.exs 18 | ``` 19 | or (if you don't have elixir installed): 20 | ``` sh 21 | docker run -i --rm -v $(pwd)/initial-setup-env.exs:/s.exs elixir:1.11-alpine elixir /s.exs 22 | ``` 23 | 24 | 3. Paste generated variables to `.env` 25 | 4. Run `docker-compose up`. This command will fetch all necessary docker images and compile entire application, so it will take a while. Then it will host it under APP_HOST:APP_PORT you selected in previous steps 26 | 5. If you selected to autopromote first registered user to administrator (which I strongly recommend), you can register and immediately log into your account (without any confirmation). 27 | 28 | Above instruction works on fresh VPS installation with DNS assigned domain (with Let's Encrypt) as well as localhost (with self-signed certificate) 29 | 30 | # License 31 | 32 | GNU GENERAL PUBLIC LICENSE Version 3 33 | -------------------------------------------------------------------------------- /TODO.org: -------------------------------------------------------------------------------- 1 | #+TITLE: TODO 2 | 3 | * TODO [0%] [0/13] 4 | ** TODO user_from_auth_token(token) -> research better method 5 | ** [?] Validation for deer record: at least one field has to be filled in 6 | ** [?] Model validations everywhere (records and tables) 7 | ** TODO Adapt layout to subscription's tables 8 | ** TODO Update account -> make Enter work 9 | ** WAIT Write high level logging/history mechanism + TEST 10 | ** TODO Write restrictions for subscriptions, sending email (registration, forget, confirmation, invite) + TESTS 11 | ** TODO Invitation can enumerate users -> think of that? 12 | ** TODO "Notes" markdown everywhere 13 | ** TODO Add "failed login attempts" to user: https://elixirforum.com/t/how-to-increment-database-table-column/15457/2 14 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/css/admin.css: -------------------------------------------------------------------------------- 1 | .admin-notes { 2 | white-space: pre-line; 3 | } 4 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | @import 'bulma.min.css'; 2 | @import 'select2.min.css'; 3 | 4 | @import 'admin.css'; 5 | @import 'global/bulma-select2.scss'; 6 | @import 'global/table.css'; 7 | @import 'global/hacks.css'; 8 | @import 'global/drop-area.css'; 9 | @import "../node_modules/nprogress/nprogress.css"; 10 | -------------------------------------------------------------------------------- /assets/css/global/bulma-select2.scss: -------------------------------------------------------------------------------- 1 | .select2-container { 2 | .select2-selection--single { 3 | height: auto !important; 4 | padding: 3px 0 !important; 5 | border: 1px solid #dbdbdb !important; 6 | 7 | .select2-selection__arrow{ 8 | top: 5px !important; 9 | } 10 | 11 | .select2-selection__placeholder { 12 | color: #dbdbdb !important; 13 | } 14 | } 15 | 16 | .select2-dropdown { 17 | border: 1px solid #dbdbdb !important; 18 | border-top: 0 !important; 19 | 20 | .select2-search { 21 | margin: 5px; 22 | 23 | .select2-search__field { 24 | padding: 10px !important; 25 | border-radius: 3px !important; 26 | font-size: 1rem; 27 | height: 2.25em; 28 | box-shadow: inset 0 1px 2px rgba(10,10,10,.1); 29 | max-width: 100%; 30 | width: 100%; 31 | border-radius: 3px !important; 32 | } 33 | } 34 | 35 | .select2-results__options { 36 | max-height: 200px !important; 37 | 38 | .select2-results__option { 39 | padding: 0.37em 0.75em !important; 40 | font-size: 1rem; 41 | 42 | &.select2-results__option--highlighted { 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /assets/css/global/drop-area.css: -------------------------------------------------------------------------------- 1 | .drop-area { 2 | border: 5px dashed #ddd; 3 | padding: 1.5rem; 4 | } 5 | .drop-area-text { 6 | font-weight: bolder; 7 | padding-bottom: 1.5rem; 8 | color: #ddd; 9 | } 10 | -------------------------------------------------------------------------------- /assets/css/global/hacks.css: -------------------------------------------------------------------------------- 1 | .modal-card {overflow: auto} 2 | .overwrite-padding-top-120 {padding-top: 120px} 3 | .overwrite-fullwidth {width: 100%} 4 | .overwrite-fullheight {height: 100%} 5 | .overwrite-width90 {width: 90%} 6 | .overwrite-height90 {height: 90%} 7 | .sticky {top: 0px; position: -webkit-sticky; position: sticky} 8 | .is-clickable {cursor: pointer} 9 | .prewrapped {white-space: pre-line;} 10 | img {max-width:100%; height:auto} 11 | .scrollable-vh75 { 12 | overflow-x: hidden; 13 | overflow-y: auto; 14 | height: 75vh; 15 | } 16 | .z-index-39 { z-index: 39; } 17 | .media-inside-modal-overwrite { 18 | max-height: 90vh; 19 | -o-object-fit: contain; 20 | object-fit: contain; 21 | } 22 | .hide-overflow {overflow: hidden;} 23 | -------------------------------------------------------------------------------- /assets/css/global/table.css: -------------------------------------------------------------------------------- 1 | /* Max width before this PARTICULAR table gets nasty. This query will take effect for any screen smaller than 760px and also iPads specifically. */ 2 | 3 | @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { 4 | /* Force table to not be like tables anymore */ 5 | table.responsive_index, 6 | table.responsive_index thead, 7 | table.responsive_index tbody, 8 | table.responsive_index th, 9 | table.responsive_index td, 10 | table.responsive_index tr { 11 | display: block; 12 | } 13 | 14 | /* Hide table headers (but not display: none;, for accessibility) */ 15 | table.responsive_index thead tr { 16 | position: absolute; 17 | top: -9999px; 18 | left: -9999px; 19 | } 20 | 21 | table.responsive_index tr { 22 | margin: 0 0 2rem 0; 23 | } 24 | 25 | table.responsive_index td { 26 | /* Behave like a "row" */ 27 | position: relative; 28 | padding-left: 50%; 29 | } 30 | 31 | table.responsive_index td:before { 32 | content: attr(data-label); 33 | font-weight: bold; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import css from "../css/app.scss" 5 | import NProgress from "nprogress" 6 | 7 | import jquery from 'jquery'; 8 | window.$ = window.jQuery = jquery 9 | 10 | import Xwiper from './xwiper'; 11 | window.Xwiper = Xwiper; 12 | 13 | // webpack automatically bundles all modules in your 14 | // entry points. Those entry points can be configured 15 | // in "webpack.config.js". 16 | // 17 | // Import dependencies 18 | 19 | import "phoenix_html" 20 | import "./select2.min.js" 21 | 22 | import "./navbar_burger" 23 | import Hooks from "./phx-hooks" 24 | 25 | // Import local files 26 | // 27 | // Local files can be imported directly using relative paths, for example: 28 | // import socket from "./socket" 29 | 30 | import {Socket} from "phoenix" 31 | import {LiveSocket} from "phoenix_live_view" 32 | 33 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); 34 | let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}}) 35 | 36 | // Show progress bar on live navigation and form submits 37 | window.addEventListener("phx:page-loading-start", info => NProgress.start()) 38 | window.addEventListener("phx:page-loading-stop", info => NProgress.done()) 39 | liveSocket.connect(); 40 | -------------------------------------------------------------------------------- /assets/js/navbar_burger.js: -------------------------------------------------------------------------------- 1 | window.hook_navbar_burger = function() { 2 | // Close mobile & tablet menu on item click 3 | $('.navbar-item').each(function(e) { 4 | $(this).click(function(){ 5 | if($('.navbar-burger').hasClass('is-active')){ 6 | $('.navbar-burger').removeClass('is-active'); 7 | $('#navigation').removeClass('is-active'); 8 | } 9 | }); 10 | }); 11 | 12 | // Open or Close mobile & tablet menu 13 | $('.navbar-burger').click(function () { 14 | if($('.navbar-burger').hasClass('is-active')){ 15 | $('.navbar-burger').removeClass('is-active'); 16 | $('#navigation').removeClass('is-active'); 17 | }else { 18 | $('.navbar-burger').addClass('is-active'); 19 | $('#navigation').addClass('is-active'); 20 | } 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /assets/js/phx-hooks.js: -------------------------------------------------------------------------------- 1 | export default { 2 | hookBurgerEvents: { 3 | mounted() {window.hook_navbar_burger()} 4 | }, 5 | dropzoneHook: { 6 | mounted() { 7 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); 8 | 9 | new Dropzone(this.el, { 10 | url: "/files/record/" + this.el.dataset.recordId, 11 | headers: {'x-csrf-token': csrfToken}, 12 | success: function (file) {this.removeFile(file);} 13 | }); 14 | } 15 | }, 16 | hookCopyUrlToClipboard: { 17 | mounted() { 18 | this.el.addEventListener("click", () => { 19 | const target = document.getElementById('created_shared_record_generated_url'); 20 | var range, select; 21 | 22 | if (document.createRange) { 23 | range = document.createRange(); 24 | range.selectNode(target) 25 | select = window.getSelection(); 26 | select.removeAllRanges(); 27 | select.addRange(range); 28 | document.execCommand('copy'); 29 | select.removeAllRanges(); 30 | } else { 31 | range = document.body.createTextRange(); 32 | range.moveToElementText(target); 33 | range.select(); 34 | document.execCommand('copy'); 35 | } 36 | }) 37 | } 38 | }, 39 | hookPreviewGestures: { 40 | mounted(){ 41 | const xwiper = new window.Xwiper(this.el); 42 | 43 | xwiper.onSwipeRight(() => {this.pushEvent("previous_file_gesture", {})}); 44 | xwiper.onSwipeLeft(() => {this.pushEvent("next_file_gesture", {})}); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket, 5 | // and connect at the socket path in "lib/web/endpoint.ex". 6 | // 7 | // Pass the token on params as below. Or remove it 8 | // from the params if you are not using authentication. 9 | import {Socket} from "phoenix" 10 | 11 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 12 | 13 | // When you connect, you'll often need to authenticate the client. 14 | // For example, imagine you have an authentication plug, `MyAuth`, 15 | // which authenticates the session and assigns a `:current_user`. 16 | // If the current user exists you can assign the user's token in 17 | // the connection for use in the layout. 18 | // 19 | // In your "lib/web/router.ex": 20 | // 21 | // pipeline :browser do 22 | // ... 23 | // plug MyAuth 24 | // plug :put_user_token 25 | // end 26 | // 27 | // defp put_user_token(conn, _) do 28 | // if current_user = conn.assigns[:current_user] do 29 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 30 | // assign(conn, :user_token, token) 31 | // else 32 | // conn 33 | // end 34 | // end 35 | // 36 | // Now you need to pass this token to JavaScript. You can do so 37 | // inside a script tag in "lib/web/templates/layout/app.html.eex": 38 | // 39 | // 40 | // 41 | // You will need to verify the user token in the "connect/3" function 42 | // in "lib/web/channels/user_socket.ex": 43 | // 44 | // def connect(%{"token" => token}, socket, _connect_info) do 45 | // # max_age: 1209600 is equivalent to two weeks in seconds 46 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 47 | // {:ok, user_id} -> 48 | // {:ok, assign(socket, :user, user_id)} 49 | // {:error, reason} -> 50 | // :error 51 | // end 52 | // end 53 | // 54 | // Finally, connect to the socket: 55 | socket.connect() 56 | 57 | // Now that you are connected, you can join channels with a topic: 58 | let channel = socket.channel("topic:subtopic", {}) 59 | channel.join() 60 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 61 | .receive("error", resp => { console.log("Unable to join", resp) }) 62 | 63 | export default socket 64 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "webpack --mode production", 6 | "watch": "webpack --mode development --watch" 7 | }, 8 | "dependencies": { 9 | "jquery": "^3.5.1", 10 | "node-sass": "^4.14.1", 11 | "nprogress": "^0.2.0", 12 | "phoenix": "file:../deps/phoenix", 13 | "phoenix_html": "file:../deps/phoenix_html", 14 | "phoenix_live_view": "file:../deps/phoenix_live_view" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.10.5", 18 | "@babel/preset-env": "^7.10.4", 19 | "babel-loader": "^8.1.0", 20 | "copy-webpack-plugin": "^5.1.1", 21 | "css-loader": "^5.2.6", 22 | "mini-css-extract-plugin": "^0.9.0", 23 | "optimize-css-assets-webpack-plugin": "^6.0.0", 24 | "sass-loader": "^8.0.2", 25 | "uglifyjs-webpack-plugin": "^1.1.2", 26 | "webpack": "^4.43.0", 27 | "webpack-cli": "^3.3.12" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /assets/static/ViewerJS/example.local.css: -------------------------------------------------------------------------------- 1 | /* This is just a sample file with CSS rules. You should write your own @font-face declarations 2 | * to add support for your desired fonts. 3 | */ 4 | 5 | @font-face { 6 | font-family: 'Novecentowide Book'; 7 | src: url("/ViewerJS/fonts/Novecentowide-Bold-webfont.eot"); 8 | src: url("/ViewerJS/fonts/Novecentowide-Bold-webfont.eot?#iefix") format("embedded-opentype"), 9 | url("/ViewerJS/fonts/Novecentowide-Bold-webfont.woff") format("woff"), 10 | url("/fonts/Novecentowide-Bold-webfont.ttf") format("truetype"), 11 | url("/fonts/Novecentowide-Bold-webfont.svg#NovecentowideBookBold") format("svg"); 12 | font-weight: normal; 13 | font-style: normal; 14 | } 15 | 16 | @font-face { 17 | font-family: 'exotica'; 18 | src: url('/ViewerJS/fonts/Exotica-webfont.eot'); 19 | src: url('/ViewerJS/fonts/Exotica-webfont.eot?#iefix') format('embedded-opentype'), 20 | url('/ViewerJS/fonts/Exotica-webfont.woff') format('woff'), 21 | url('/ViewerJS/fonts/Exotica-webfont.ttf') format('truetype'), 22 | url('/ViewerJS/fonts/Exotica-webfont.svg#exoticamedium') format('svg'); 23 | font-weight: normal; 24 | font-style: normal; 25 | 26 | } 27 | 28 | -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/kogmbh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/kogmbh.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/nlnet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/nlnet.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/texture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/texture.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/toolbarButton-download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/toolbarButton-download.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/toolbarButton-fullscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/toolbarButton-fullscreen.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/toolbarButton-menuArrows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/toolbarButton-menuArrows.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/toolbarButton-pageDown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/toolbarButton-pageDown.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/toolbarButton-pageUp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/toolbarButton-pageUp.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/toolbarButton-presentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/toolbarButton-presentation.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/toolbarButton-zoomIn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/toolbarButton-zoomIn.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/images/toolbarButton-zoomOut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/ViewerJS/images/toolbarButton-zoomOut.png -------------------------------------------------------------------------------- /assets/static/ViewerJS/pdfjsversion.js: -------------------------------------------------------------------------------- 1 | var /**@const{!string}*/pdfjs_version = "v1.1.114"; 2 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | module.exports = (env, options) => ({ 9 | optimization: { 10 | minimizer: [ 11 | new UglifyJsPlugin({ cache: true, parallel: true, sourceMap: false }), 12 | new OptimizeCSSAssetsPlugin({}) 13 | ] 14 | }, 15 | entry: { 16 | './js/app.js': glob.sync('./vendor/**/*.js').concat(['./js/app.js']) 17 | }, 18 | output: { 19 | filename: 'app.js', 20 | path: path.resolve(__dirname, '../priv/static/js') 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.js$/, 26 | exclude: /node_modules/, 27 | use: { 28 | loader: 'babel-loader' 29 | } 30 | }, 31 | { 32 | test: /\.s?css$/, 33 | use: [ 34 | MiniCssExtractPlugin.loader, 35 | { 36 | loader: 'css-loader', 37 | options: {} 38 | }, 39 | { 40 | loader: 'sass-loader', 41 | options: {} 42 | } 43 | ] 44 | } 45 | ] 46 | }, 47 | plugins: [ 48 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 49 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }]), 50 | ] 51 | }); 52 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | use Mix.Config 9 | # TODO: use Config module: https://hexdocs.pm/elixir/Config.html 10 | 11 | config :deer_storage, 12 | ecto_repos: [DeerStorage.Repo] 13 | 14 | config :deer_storage, DeerStorage.Gettext, default_locale: "en", locales: ~w(en pl) 15 | 16 | # Configures the endpoint 17 | config :deer_storage, DeerStorageWeb.Endpoint, 18 | url: [host: "localhost"], 19 | secret_key_base: "Kkpy/olWADIHhc0fK7C/K2YuBOE3u2BTzJZAnqeI59WAH33cl57Snvee6xGETJfm", 20 | render_errors: [view: DeerStorageWeb.ErrorView, accepts: ~w(html json)], 21 | pubsub_server: DeerStorage.PubSub, 22 | live_view: [ 23 | signing_salt: "Cx4+NQzV+jnvqWiZKk+v0u1YPxyS/vIg", # overwrite in production 24 | hibernate_after: 3_600_000 # 1 hour 25 | ] 26 | 27 | # Configures Elixir's Logger 28 | config :logger, :console, 29 | format: "$time $metadata[$level] $message\n", 30 | metadata: [:request_id] 31 | 32 | # Use Jason for JSON parsing in Phoenix 33 | config :phoenix, :json_library, Jason 34 | 35 | # Pow (authentication) 36 | config :deer_storage, :pow, 37 | user: DeerStorage.Users.User, 38 | repo: DeerStorage.Repo, 39 | mailer_backend: DeerStorageWeb.PowMailer, 40 | web_module: DeerStorageWeb, 41 | web_mailer_module: DeerStorageWeb, 42 | cache_store_backend: Pow.Store.Backend.MnesiaCache, 43 | extensions: [PowResetPassword, PowEmailConfirmation, PowPersistentSession, PowInvitation] 44 | # controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks 45 | 46 | # Import environment specific config. This must remain at the bottom 47 | # of this file so it overrides the configuration defined above. 48 | import_config "#{Mix.env()}.exs" 49 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :deer_storage, DeerStorage.Repo, 5 | username: "pjeski", 6 | password: "pjeski", 7 | database: "deer_storage_dev", 8 | hostname: "localhost", 9 | show_sensitive_data_on_connection_error: true, 10 | pool_size: 10 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | # 15 | # The watchers configuration can be used to run external 16 | # watchers to your application. For example, we use it 17 | # with webpack to recompile .js and .css sources. 18 | config :deer_storage, DeerStorageWeb.Endpoint, 19 | http: [port: 4000], 20 | debug_errors: true, 21 | code_reloader: true, 22 | check_origin: false, 23 | watchers: [ 24 | node: [ 25 | "node_modules/webpack/bin/webpack.js", 26 | "--mode", 27 | "development", 28 | "--watch-stdin", 29 | cd: Path.expand("../assets", __DIR__) 30 | ] 31 | ] 32 | 33 | # ## SSL Support 34 | # 35 | # In order to use HTTPS in development, a self-signed 36 | # certificate can be generated by running the following 37 | # Mix task: 38 | # 39 | # mix phx.gen.cert 40 | # 41 | # Note that this task requires Erlang/OTP 20 or later. 42 | # Run `mix help phx.gen.cert` for more information. 43 | # 44 | # The `http:` config above can be replaced with: 45 | # 46 | # https: [ 47 | # port: 4001, 48 | # cipher_suite: :strong, 49 | # keyfile: "priv/cert/selfsigned_key.pem", 50 | # certfile: "priv/cert/selfsigned.pem" 51 | # ], 52 | # 53 | # If desired, both `http:` and `https:` keys can be 54 | # configured to run both http and https servers on 55 | # different ports. 56 | 57 | # Watch static and templates for browser reloading. 58 | config :deer_storage, DeerStorageWeb.Endpoint, 59 | live_reload: [ 60 | patterns: [ 61 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 62 | ~r"priv/gettext/.*(po)$", 63 | ~r"lib/deer_storage_web/{live,views}/.*(ex)$", 64 | ~r"lib/deer_storage_web/templates/.*(eex)$" 65 | ] 66 | ] 67 | 68 | # Do not include metadata nor timestamps in development logs 69 | config :logger, :console, format: "[$level] $message\n" 70 | 71 | # Set a higher stacktrace during development. Avoid configuring such 72 | # in production as building large stacktraces may be expensive. 73 | config :phoenix, :stacktrace_depth, 20 74 | 75 | # Initialize plugs at runtime for faster development compilation 76 | config :phoenix, :plug_init_mode, :runtime 77 | 78 | config :deer_storage, DeerStorageWeb.PowMailer, adapter: Bamboo.LocalAdapter 79 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | # Note we also include the path to a cache manifest 3 | # containing the digested version of static files. This 4 | # manifest is generated by the `mix phx.digest` task, 5 | # which you should run after static files are built and 6 | # before starting your production server. 7 | config :deer_storage, DeerStorageWeb.Endpoint, 8 | url: [host: System.get_env("APP_HOST"), port: 80], 9 | cache_static_manifest: "priv/static/cache_manifest.json", 10 | server: true 11 | 12 | # Log to console because of Docker 13 | config :logger, :console, level: :info 14 | 15 | # ## SSL Support 16 | # 17 | # To get SSL working, you will need to add the `https` key 18 | # to the previous section and set your `:url` port to 443: 19 | # 20 | # config :deer_storage, DeerStorageWeb.Endpoint, 21 | # ... 22 | # url: [host: "example.com", port: 443], 23 | # https: [ 24 | # :inet6, 25 | # port: 443, 26 | # cipher_suite: :strong, 27 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 28 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 29 | # ] 30 | # 31 | # The `cipher_suite` is set to `:strong` to support only the 32 | # latest and more secure SSL ciphers. This means old browsers 33 | # and clients may not be supported. You can set it to 34 | # `:compatible` for wider support. 35 | # 36 | # `:keyfile` and `:certfile` expect an absolute path to the key 37 | # and cert in disk or a relative path inside priv, for example 38 | # "priv/ssl/server.key". For all supported SSL configuration 39 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 40 | # 41 | # We also recommend setting `force_ssl` in your endpoint, ensuring 42 | # no data is ever sent via http, always redirecting to https: 43 | # 44 | # config :deer_storage, DeerStorageWeb.Endpoint, 45 | # force_ssl: [hsts: true] 46 | # 47 | # Check `Plug.SSL` for all available options in `force_ssl`. 48 | 49 | # Finally import the config/prod.secret.exs which loads secrets 50 | # and configuration from environment variables. 51 | # moved to releases.exs // import_config "prod.secret.exs" 52 | -------------------------------------------------------------------------------- /config/releases.exs: -------------------------------------------------------------------------------- 1 | # In this file, we load production configuration and secrets 2 | # from environment variables. You can also hardcode secrets, 3 | # although such is generally not recommended and you have to 4 | # remember to add this file to your .gitignore. 5 | import Config 6 | 7 | config :deer_storage, DeerStorage.Repo, 8 | # ssl: true, 9 | username: System.fetch_env!("PGUSER"), 10 | password: System.fetch_env!("PGPASSWORD"), 11 | database: System.fetch_env!("PGDATABASE"), 12 | hostname: System.fetch_env!("PGHOST"), 13 | port: System.fetch_env!("PGPORT") |> String.to_integer, 14 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 15 | 16 | config :deer_storage, DeerStorageWeb.Endpoint, 17 | url: [host: {:system, "APP_HOST"}, port: 443, scheme: "https"], 18 | http: [port: 80], 19 | server: true, 20 | secret_key_base: System.fetch_env!("SECRET_KEY_BASE") 21 | 22 | if System.get_env("POW_MAILGUN_API_KEY") do 23 | config :deer_storage, 24 | DeerStorageWeb.PowMailer, 25 | adapter: Bamboo.MailgunAdapter, 26 | base_uri: System.fetch_env!("POW_MAILGUN_BASE_URI"), 27 | domain: System.fetch_env!("POW_MAILGUN_DOMAIN"), 28 | api_key: System.fetch_env!("POW_MAILGUN_API_KEY"), 29 | hackney_opts: [recv_timeout: :timer.minutes(1)] 30 | else 31 | # TODO: this does not work inside docker 32 | config :deer_storage, DeerStorageWeb.PowMailer, adapter: Bamboo.LocalAdapter 33 | end 34 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :deer_storage, DeerStorage.Repo, 5 | username: "pjeski", 6 | password: "pjeski", 7 | database: "deer_storage_test", 8 | hostname: "localhost", 9 | secret_key_base: String.duplicate("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 2), 10 | pool: Ecto.Adapters.SQL.Sandbox 11 | 12 | # We don't run a server during test. If one is required, 13 | # you can enable the server option below. 14 | config :deer_storage, DeerStorageWeb.Endpoint, 15 | http: [port: 4002], 16 | server: false 17 | 18 | # Print only warnings and errors during test 19 | config :logger, level: :warn 20 | 21 | config :deer_storage, DeerStorageWeb.PowMailer, adapter: Bamboo.TestAdapter 22 | 23 | config :deer_storage, :pow, cache_store_backend: DeerStorageWeb.EtsCacheMock, 24 | message_verifier: DeerStorage.Test.Pow.MessageVerifier, 25 | cache_store_backend: Pow.Store.Backend.EtsCacheMock 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | nginx-proxy: 5 | build: 6 | dockerfile: ./Dockerfile.nginx-proxy 7 | context: ./docker-data 8 | env_file: 9 | - .env 10 | environment: 11 | PHOENIX: phoenix 12 | ports: 13 | - "${APP_HTTP_PORT}:80" 14 | - "${APP_HTTPS_PORT}:443" 15 | volumes: 16 | - config_and_certificates:/config_and_certificates 17 | - certbot_www:/var/www/certbot 18 | depends_on: 19 | - phoenix 20 | restart: always 21 | certbot: 22 | build: 23 | dockerfile: ./Dockerfile.certbot 24 | context: ./docker-data 25 | env_file: 26 | - .env 27 | volumes: 28 | - certbot_www:/var/www/certbot 29 | - config_and_certificates:/config_and_certificates 30 | restart: always 31 | depends_on: 32 | - nginx-proxy 33 | phoenix: 34 | image: deer_storage-prod 35 | build: 36 | context: ./ 37 | dockerfile: ./docker-data/Dockerfile.phoenix 38 | args: 39 | NEW_SUBSCRIPTION_COLUMNS_PER_TABLE_LIMIT: $NEW_SUBSCRIPTION_COLUMNS_PER_TABLE_LIMIT 40 | NEW_SUBSCRIPTION_DAYS_TO_EXPIRE: $NEW_SUBSCRIPTION_DAYS_TO_EXPIRE 41 | NEW_SUBSCRIPTION_FILES_COUNT_LIMIT: $NEW_SUBSCRIPTION_FILES_COUNT_LIMIT 42 | NEW_SUBSCRIPTION_RECORDS_PER_TABLE_LIMIT: $NEW_SUBSCRIPTION_RECORDS_PER_TABLE_LIMIT 43 | NEW_SUBSCRIPTION_STORAGE_LIMIT_IN_KILOBYTES: $NEW_SUBSCRIPTION_STORAGE_LIMIT_IN_KILOBYTES 44 | NEW_SUBSCRIPTION_TABLES_LIMIT: $NEW_SUBSCRIPTION_TABLES_LIMIT 45 | env_file: 46 | - .env 47 | environment: 48 | SECRET_KEY_BASE: "${SECRET_KEY_BASE}" 49 | APP_HOST: "${APP_HOST}" 50 | depends_on: 51 | - db 52 | volumes: 53 | - uploaded_files:/opt/app/uploaded_files 54 | restart: always 55 | db: 56 | image: postgres:11-alpine 57 | environment: 58 | POSTGRES_USER: "${PGUSER}" 59 | POSTGRES_PASSWORD: "${PGPASSWORD}" 60 | POSTGRES_DB: "${PGDATABASE}" 61 | PGDATA: /var/lib/postgresql/data/pgdata 62 | restart: always 63 | volumes: 64 | - pgdata:/var/lib/postgresql/data 65 | volumes: 66 | pgdata: 67 | uploaded_files: 68 | config_and_certificates: 69 | certbot_www: 70 | -------------------------------------------------------------------------------- /docker-data/Dockerfile.certbot: -------------------------------------------------------------------------------- 1 | FROM certbot/certbot:latest 2 | 3 | RUN apk --no-cache --update add bash curl 4 | 5 | COPY certbot-entrypoint.sh /certbot-entrypoint.sh 6 | RUN ["chmod", "+x", "/certbot-entrypoint.sh"] 7 | 8 | ENTRYPOINT ["/bin/bash", "-c"] 9 | CMD ["/certbot-entrypoint.sh"] 10 | -------------------------------------------------------------------------------- /docker-data/Dockerfile.nginx-proxy: -------------------------------------------------------------------------------- 1 | FROM nginx:1.19-alpine 2 | 3 | RUN apk --no-cache --update add bash openssl 4 | 5 | COPY default.conf.template /etc/nginx/templates/ 6 | COPY nginx-proxy-reloading-script.sh /docker-entrypoint.d/nginx-proxy-reloading-script.sh 7 | -------------------------------------------------------------------------------- /docker-data/Dockerfile.phoenix: -------------------------------------------------------------------------------- 1 | FROM bitwalker/alpine-elixir:1.11.0 AS build 2 | 3 | # Install NPM 4 | RUN \ 5 | mkdir -p /opt/app && \ 6 | chmod -R 777 /opt/app && \ 7 | apk update && \ 8 | apk --no-cache --update add \ 9 | make \ 10 | g++ \ 11 | wget \ 12 | curl \ 13 | python3 \ 14 | inotify-tools \ 15 | nodejs \ 16 | nodejs-npm && \ 17 | npm install npm -g --no-progress && \ 18 | update-ca-certificates --fresh && \ 19 | rm -rf /var/cache/apk/* 20 | 21 | # Add local node module binaries to PATH 22 | ENV PATH=./node_modules/.bin:$PATH 23 | 24 | # Ensure latest versions of Hex/Rebar are installed on build 25 | ONBUILD RUN mix do local.hex --force, local.rebar --force 26 | 27 | WORKDIR /opt/app 28 | 29 | RUN mix local.hex --force && \ 30 | mix local.rebar --force 31 | 32 | ENV MIX_ENV=prod 33 | 34 | COPY mix.exs mix.lock ./ 35 | COPY config config 36 | RUN mix do deps.get, deps.compile 37 | 38 | COPY assets/package.json assets/package-lock.json ./assets/ 39 | RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error 40 | 41 | COPY priv priv 42 | COPY assets assets 43 | RUN npm run --prefix ./assets deploy 44 | RUN mix phx.digest 45 | 46 | COPY lib lib 47 | 48 | # uncomment COPY if rel/ exists 49 | # COPY rel rel 50 | 51 | ARG NEW_SUBSCRIPTION_COLUMNS_PER_TABLE_LIMIT 52 | ARG NEW_SUBSCRIPTION_DAYS_TO_EXPIRE 53 | ARG NEW_SUBSCRIPTION_FILES_COUNT_LIMIT 54 | ARG NEW_SUBSCRIPTION_RECORDS_PER_TABLE_LIMIT 55 | ARG NEW_SUBSCRIPTION_STORAGE_LIMIT_IN_KILOBYTES 56 | ARG NEW_SUBSCRIPTION_TABLES_LIMIT 57 | 58 | RUN mix do compile, release 59 | 60 | FROM bitwalker/alpine-erlang:23 AS app 61 | 62 | RUN apk --no-cache --no-progress add -q postgresql-client=11.12-r0 --repository=http://dl-cdn.alpinelinux.org/alpine/v3.10/main 63 | RUN apk --no-cache --no-progress add -q coreutils file 64 | 65 | WORKDIR /opt/app 66 | 67 | COPY --from=build /opt/app/_build/prod/rel/deer_storage ./ 68 | RUN ["chmod", "+x", "/opt/app/bin/deer_storage"] 69 | RUN ["chmod", "+x", "/opt/app/releases/0.1.0/elixir"] 70 | 71 | COPY ./docker-data/phoenix-entrypoint.sh /opt/app 72 | 73 | ENTRYPOINT ["/bin/bash", "-c"] 74 | CMD ["/opt/app/phoenix-entrypoint.sh"] 75 | -------------------------------------------------------------------------------- /docker-data/default.conf.template: -------------------------------------------------------------------------------- 1 | map $http_upgrade $connection_upgrade { 2 | default upgrade; 3 | '' close; 4 | } 5 | 6 | server { 7 | listen 80; 8 | server_name ${APP_HOST}; 9 | 10 | location /.well-known/acme-challenge/ { 11 | root /var/www/certbot; 12 | } 13 | 14 | location / { 15 | return 301 https://$host$request_uri; 16 | } 17 | } 18 | 19 | server { 20 | listen 443 ssl; 21 | server_name ${APP_HOST}; 22 | 23 | proxy_max_temp_file_size 0; 24 | 25 | ssl_protocols TLSv1.2 TLSv1.3; 26 | ssl_prefer_server_ciphers on; 27 | ssl_ciphers EECDH+AESGCM:EDH+AESGCM; 28 | ssl_ecdh_curve secp384r1; 29 | 30 | ssl_stapling on; 31 | ssl_stapling_verify on; 32 | resolver 127.0.0.1 8.8.8.8 8.8.4.4 valid=300s; 33 | 34 | add_header Strict-Transport-Security "max-age=15768000; includeSubdomains; preload;"; 35 | 36 | ssl_dhparam /config_and_certificates/ssl-dhparams.pem; 37 | ssl_certificate /config_and_certificates/fullchain.pem; 38 | ssl_certificate_key /config_and_certificates/privkey.pem; 39 | 40 | # Proxy Headers 41 | proxy_http_version 1.1; 42 | proxy_set_header Host $http_host; 43 | proxy_set_header X-Real-IP $remote_addr; 44 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 45 | 46 | location / { 47 | proxy_pass http://${PHOENIX}; 48 | } 49 | 50 | location /live { 51 | # The Important Websocket Bits! 52 | proxy_set_header Upgrade $http_upgrade; 53 | proxy_set_header Connection $connection_upgrade; 54 | 55 | proxy_pass http://${PHOENIX}; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docker-data/nginx-proxy-reloading-script.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | data_path="/config_and_certificates" 5 | 6 | reload_nginx () { 7 | echo "Reloading nginx..." 8 | nginx -s reload 9 | } 10 | 11 | loop_reloading_nginx_every_6h () { 12 | while [ $LETSENCRYPT_ENABLED == 1 ]; do 13 | echo "Will reload Nginx in 6h..." 14 | sleep 6h 15 | reload_nginx 16 | done 17 | } 18 | 19 | certificate_matching_letsencrypt () { 20 | openssl x509 -in ${data_path}/fullchain.pem -text | grep -c "Let's Encrypt" 21 | } 22 | 23 | if [ $LETSENCRYPT_ENABLED == 1 ]; then 24 | echo "### Looping nginx-proxy reloading script..." 25 | 26 | while [ "$(certificate_matching_letsencrypt)" -eq 0 ]; do 27 | echo "Waiting for Let's Encrypt certificate..." 28 | sleep 1; 29 | done; 30 | 31 | # TODO what if it's expired? loop: wait a couple of seconds and check again 32 | reload_nginx 33 | loop_reloading_nginx_every_6h 34 | fi & 35 | -------------------------------------------------------------------------------- /docker-data/phoenix-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | echo "Running phoenix-entrypoint.sh..." 5 | 6 | while ! pg_isready -q -h $PGHOST -p $PGPORT -U $PGUSER 7 | do 8 | echo "$(date) - waiting for database container to start" 9 | sleep 1 10 | done 11 | 12 | if [[ -z `psql -Atqc "\\list $PGDATABASE"` ]]; then 13 | echo "Database $PGDATABASE does not exist. Creating..." 14 | createdb $PGDATABASE 15 | echo "Database $PGDATABASE created." 16 | fi 17 | 18 | echo "Running migrations..." 19 | bin/deer_storage eval "DeerStorage.Release.migrate" 20 | 21 | echo "Starting application..." 22 | exec bin/deer_storage start 23 | -------------------------------------------------------------------------------- /docs/old-database-uml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/docs/old-database-uml.png -------------------------------------------------------------------------------- /lib/deer_cache/records_counts_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerCache.RecordsCountsCache do 2 | use GenServer 3 | alias Phoenix.PubSub 4 | 5 | import DeerStorage.DeerRecords, only: [count_records_grouped_by_deer_table_id: 0] 6 | 7 | def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, [{:ets_table_name, :deer_records_by_table_id_cache}], opts) 8 | 9 | def fetch_count(table_id) do 10 | case GenServer.call(__MODULE__, {:get, table_id}) do 11 | [] -> 0 12 | [{_table_id, count}] -> count 13 | end 14 | end 15 | 16 | def handle_call({:get, deer_table_id}, _from, state) do 17 | {:reply, get(deer_table_id, state), state} 18 | end 19 | 20 | def handle_call({:deleted_table, deer_table_id}, _from, state), do: {:reply, delete(deer_table_id, state), state} 21 | 22 | def handle_cast({:increment, deer_table_id, by_count}, state) do 23 | new_count = case get(deer_table_id, state) do 24 | [] -> by_count 25 | [{^deer_table_id, n}] -> n + by_count 26 | end 27 | 28 | set(deer_table_id, new_count, state) 29 | 30 | PubSub.broadcast DeerStorage.PubSub, "records_counts:#{deer_table_id}", {:cached_records_count_changed, deer_table_id, new_count} 31 | 32 | {:noreply, state} 33 | end 34 | 35 | def handle_cast({:decrement, deer_table_id, by_count}, state) do 36 | [{^deer_table_id, count}] = get(deer_table_id, state) 37 | new_count = count - by_count 38 | 39 | set(deer_table_id, new_count, state) 40 | 41 | PubSub.broadcast DeerStorage.PubSub, "records_counts:#{deer_table_id}", {:cached_records_count_changed, deer_table_id, new_count} 42 | 43 | {:noreply, state} 44 | end 45 | 46 | def init(args) do 47 | [{:ets_table_name, ets_table_name}] = args 48 | :ets.new(ets_table_name, [:named_table, :set, :private]) 49 | grouped_counts = count_records_grouped_by_deer_table_id() 50 | state = %{ets_table_name: ets_table_name} 51 | 52 | for %{deer_table_id: id, count: count} <- grouped_counts, do: set(id, count, state) 53 | 54 | {:ok, state} 55 | end 56 | 57 | defp set(table_id, count, %{ets_table_name: ets_table_name}) do 58 | true = :ets.insert(ets_table_name, {table_id, count}) 59 | end 60 | 61 | defp get(table_id, %{ets_table_name: ets_table_name}) do 62 | :ets.lookup(ets_table_name, table_id) 63 | end 64 | 65 | defp delete(table_id, %{ets_table_name: ets_table_name}) do 66 | :ets.delete(ets_table_name, table_id) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/deer_cache/subscription_storage_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerCache.SubscriptionStorageCache do 2 | use GenServer 3 | alias Phoenix.PubSub 4 | 5 | def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, [{:ets_table_name, :deer_files_disk_usage_by_subscription_id}], opts) 6 | 7 | def fetch_data(subscription_id) do 8 | case GenServer.call(__MODULE__, {:get, subscription_id}) do 9 | [] -> {0, 0} 10 | [{_subscription_id, data}] -> data 11 | end 12 | end 13 | 14 | def handle_call({:get, subscription_id}, _from, state) do 15 | {:reply, get(subscription_id, state), state} 16 | end 17 | 18 | def handle_cast({:uploaded_file, subscription_id, file_size_in_kilobytes}, state) do 19 | new_data = case get(subscription_id, state) do 20 | [] -> {1, file_size_in_kilobytes} 21 | [{^subscription_id, {files, kilobytes}}] -> {files + 1, kilobytes + file_size_in_kilobytes} 22 | end 23 | 24 | set(subscription_id, new_data, state) 25 | 26 | PubSub.broadcast DeerStorage.PubSub, "subscription_deer_storage:#{subscription_id}", {:cached_deer_storage_changed, new_data} 27 | 28 | {:noreply, state} 29 | end 30 | 31 | def handle_cast({:removed_files, subscription_id, count, file_size_in_kilobytes}, state) do 32 | [{^subscription_id, {files, kilobytes}}] = get(subscription_id, state) 33 | new_data = {files - count, kilobytes - file_size_in_kilobytes} 34 | 35 | set(subscription_id, new_data, state) 36 | 37 | PubSub.broadcast DeerStorage.PubSub, "subscription_deer_storage:#{subscription_id}", {:cached_deer_storage_changed, new_data} 38 | 39 | {:noreply, state} 40 | end 41 | 42 | def init(args) do 43 | [{:ets_table_name, ets_table_name}] = args 44 | :ets.new(ets_table_name, [:named_table, :set, :private]) 45 | grouped_data = DeerStorage.Services.CalculateDeerStorage.run!() 46 | state = %{ets_table_name: ets_table_name} 47 | 48 | for {subscription_id, {files, kilobytes}} <- grouped_data, do: set(subscription_id, {files, kilobytes}, state) 49 | 50 | {:ok, state} 51 | end 52 | 53 | defp set(subscription_id, data, %{ets_table_name: ets_table_name}) do 54 | true = :ets.insert(ets_table_name, {subscription_id, data}) 55 | end 56 | 57 | defp get(subscription_id, %{ets_table_name: ets_table_name}) do 58 | :ets.lookup(ets_table_name, subscription_id) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/deer_cache/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerCache.Supervisor do 2 | use Supervisor 3 | 4 | def start_link(_opts) do 5 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 6 | end 7 | 8 | def init(:ok) do 9 | children = [ 10 | worker(DeerCache.RecordsCountsCache, [[name: DeerCache.RecordsCountsCache]]), 11 | worker(DeerCache.SubscriptionStorageCache, [[name: DeerCache.SubscriptionStorageCache]]), 12 | worker(DeerStorage.DeleteOutdatedSharedRecordsAndFilesEvery24h, []) 13 | ] 14 | 15 | Supervisor.init(children, strategy: :one_for_one) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/deer_storage.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage do 2 | @moduledoc """ 3 | DeerStorage keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/deer_storage/application.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | # List all child processes to be supervised 10 | children = [ 11 | {Phoenix.PubSub, [name: DeerStorage.PubSub, adapter: Phoenix.PubSub.PG2]}, 12 | DeerStorage.Repo, 13 | DeerCache.Supervisor, 14 | DeerStorageWeb.Telemetry, 15 | DeerStorageWeb.Endpoint, 16 | Pow.Store.Backend.MnesiaCache, 17 | Pow.Store.Backend.MnesiaCache.Unsplit # Recover from netsplit 18 | ] 19 | 20 | # See https://hexdocs.pm/elixir/Supervisor.html 21 | # for other strategies and supported options 22 | opts = [strategy: :one_for_one, name: DeerStorage.Supervisor] 23 | Supervisor.start_link(children, opts) 24 | end 25 | 26 | # Tell Phoenix to update the endpoint configuration 27 | # whenever the application is updated. 28 | def config_change(changed, _new, removed) do 29 | DeerStorageWeb.Endpoint.config_change(changed, removed) 30 | :ok 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/deer_storage/db_helpers/compose_search_query.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.DbHelpers.ComposeSearchQuery do 2 | import Ecto.Query 3 | 4 | def compose_search_query(columns, string) do 5 | filters = string 6 | |> String.replace("*", "%") 7 | |> String.split 8 | |> Enum.map(fn word -> 9 | wrapped_word = "%#{word}%" 10 | Keyword.new columns, (fn key -> {key, wrapped_word} end) 11 | end) 12 | 13 | dynamic(^recursive_dynamic_query(filters)) 14 | end 15 | 16 | defp recursive_dynamic_query([{key, value} | []]), do: dynamic(^recursive_dynamic_query(key, value)) 17 | defp recursive_dynamic_query([arr | []]), do: dynamic(^recursive_dynamic_query(arr)) 18 | defp recursive_dynamic_query([{key, value}|rest]) do 19 | dynamic(^recursive_dynamic_query(key, value) or ^recursive_dynamic_query(rest)) 20 | end 21 | defp recursive_dynamic_query([first | rest]) when length(rest) < 5 do 22 | dynamic(^recursive_dynamic_query(first) and ^recursive_dynamic_query(rest)) 23 | end 24 | defp recursive_dynamic_query(_), do: nil 25 | defp recursive_dynamic_query(key, value) do 26 | dynamic([q], fragment("unaccent(?) ILIKE unaccent(?)", field(q, ^key), ^value)) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/deer_storage/db_helpers/deer_records_search.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.DbHelpers.DeerRecordsSearch do 2 | import Ecto.Query 3 | 4 | alias DeerStorage.Repo 5 | alias DeerStorage.DeerRecords.DeerRecord 6 | 7 | def per_page(), do: 30 8 | 9 | def prepare_search_query(""), do: [] 10 | def prepare_search_query(nil), do: [] 11 | def prepare_search_query(query_string) do 12 | query_string 13 | |> String.replace("%", "\\%") 14 | |> String.replace("*", "%") 15 | |> String.split 16 | end 17 | 18 | def search_records(subscription_id, table_id, query_list, page) when is_list(query_list) do 19 | initial_query = DeerRecord 20 | |> where([dr], dr.subscription_id == ^subscription_id and dr.deer_table_id == ^table_id) 21 | |> offset(^calculate_offset(page)) 22 | |> limit(^per_page()) 23 | |> order_by(desc: :updated_at) 24 | 25 | query = case query_list do 26 | [] -> initial_query 27 | _ -> initial_query |> where(^recursive_dynamic_query(query_list)) 28 | end 29 | 30 | Repo.all(query) 31 | end 32 | 33 | defp recursive_dynamic_query([head| []]), do: dynamic(^recursive_dynamic_query(head)) 34 | defp recursive_dynamic_query([head | tail]), do: dynamic(^recursive_dynamic_query(head) and ^recursive_dynamic_query(tail)) 35 | defp recursive_dynamic_query(word) do 36 | word = "%#{word}%" 37 | 38 | matched_fields = dynamic([q], fragment("exists (select * from unnest(?) obj where obj->>'content' ilike ?)", field(q, :deer_fields), ^word)) 39 | matched_files = dynamic([q], fragment("exists (select * from unnest(?) obj where obj->>'original_filename' ilike ?)", field(q, :deer_files), ^word)) 40 | 41 | dynamic(^matched_fields or ^matched_files) 42 | end 43 | 44 | defp calculate_offset(page) when page > 0, do: (page - 1) * per_page() 45 | end 46 | -------------------------------------------------------------------------------- /lib/deer_storage/deer_records/deer_field.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.DeerRecords.DeerField do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key {:deer_column_id, :binary_id, autogenerate: false} 6 | embedded_schema do 7 | field :content, :string 8 | end 9 | 10 | @doc false 11 | def changeset(deer_field, attrs, [deer_table_id: table_id, subscription: subscription]) do 12 | deer_field 13 | |> cast(attrs, [:deer_column_id, :content]) 14 | |> validate_tables_and_columns_integrity(table_id, subscription) 15 | |> validate_length(:content, max: 200) 16 | end 17 | 18 | defp validate_tables_and_columns_integrity(changeset, table_id, subscription) do 19 | case Enum.find(subscription.deer_tables, fn table -> table.id == table_id end) do 20 | nil -> 21 | add_error(changeset, :deer_column_id, "empty") 22 | deer_table -> 23 | validate_inclusion(changeset, :deer_column_id, Enum.map(deer_table.deer_columns, &(&1.id))) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/deer_storage/deer_records/deer_file.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.DeerRecords.DeerFile do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key {:id, :string, autogenerate: false} 6 | embedded_schema do 7 | field :original_filename, :string 8 | field :kilobytes, :integer 9 | field :uploaded_by_user_id, :id 10 | field :mimetype, :string 11 | 12 | timestamps(updated_at: false) 13 | end 14 | 15 | @doc false 16 | def changeset(deer_file, attrs) do 17 | deer_file 18 | |> cast(attrs, [:id, :kilobytes, :original_filename, :uploaded_by_user_id, :mimetype]) 19 | |> validate_required([:id, :kilobytes, :original_filename, :uploaded_by_user_id, :mimetype]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/deer_storage/delete_outdated_shared_records_and_files_every_24h.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.DeleteOutdatedSharedRecordsAndFilesEvery24h do 2 | require Logger 3 | use GenServer 4 | 5 | def start_link do 6 | Logger.info("Starting DeleteOutdatedSharedRecordsAndFilesEvery24h worker...") 7 | GenServer.start_link(__MODULE__, %{}) 8 | end 9 | 10 | def init(state) do 11 | send(self(), :work) 12 | {:ok, state} 13 | end 14 | 15 | def handle_info(:work, state) do 16 | {records_count, _} = DeerStorage.SharedRecords.delete_outdated! 17 | {files_count, _} = DeerStorage.SharedFiles.delete_outdated! 18 | 19 | Logger.info("Deleted #{records_count} outdated shared records and #{files_count} outdated shared files") 20 | 21 | schedule_next_work() 22 | 23 | {:noreply, state} 24 | end 25 | 26 | defp schedule_next_work() do 27 | Process.send_after(self(), :work, 86_400_000) # 1 day in milliseconds 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/deer_storage/ecto_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.EctoHelpers do 2 | def reset_errors(changeset) do 3 | %{changeset | errors: [], valid?: true} 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/deer_storage/feature_flags.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.FeatureFlags do 2 | def registration_enabled?, do: System.get_env("FEATURE_REGISTRATION") == "1" 3 | def promote_first_user_to_admin_enabled?, do: System.get_env("FEATURE_AUTOCONFIRM_AND_PROMOTE_FIRST_USER_TO_ADMIN") == "1" 4 | def mailing_enabled? do 5 | result = present_env?("POW_MAILGUN_BASE_URI") && 6 | present_env?("POW_MAILGUN_DOMAIN") && 7 | present_env?("POW_MAILGUN_API_KEY") 8 | 9 | !!result 10 | end 11 | 12 | def mailing_disabled? do 13 | result = empty_env?("POW_MAILGUN_BASE_URI") || 14 | empty_env?("POW_MAILGUN_DOMAIN") || 15 | empty_env?("POW_MAILGUN_API_KEY") 16 | 17 | !!result 18 | end 19 | 20 | defp empty_env?(env_string), do: empty?(System.get_env(env_string)) 21 | defp present_env?(env_string), do: present?(System.get_env(env_string)) 22 | 23 | defp empty?(""), do: true 24 | defp empty?(nil), do: true 25 | defp empty?(_), do: false 26 | 27 | defp present?(""), do: false 28 | defp present?(nil), do: false 29 | defp present?(_), do: true 30 | 31 | end 32 | -------------------------------------------------------------------------------- /lib/deer_storage/release.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Release do 2 | @app :deer_storage 3 | 4 | def migrate do 5 | load_app() 6 | 7 | for repo <- repos() do 8 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 9 | end 10 | end 11 | 12 | def rollback(repo, version) do 13 | load_app() 14 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 15 | end 16 | 17 | defp repos do 18 | Application.fetch_env!(@app, :ecto_repos) 19 | end 20 | 21 | defp load_app do 22 | Application.load(@app) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/deer_storage/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo do 2 | use Ecto.Repo, 3 | otp_app: :deer_storage, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/deer_storage/services/calculate_deer_storage.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Services.CalculateDeerStorage do 2 | alias DeerStorage.DeerRecords.DeerRecord 3 | alias DeerStorage.Repo 4 | import Ecto.Query, warn: false 5 | 6 | import DeerStorage.DeerRecords.DeerRecord, only: [deer_files_stats: 1] 7 | 8 | def run! do 9 | minimal_records = Repo.all( 10 | from r in DeerRecord, 11 | select: [:subscription_id, :deer_files], 12 | where: fragment("cardinality(?) > 0", field(r, :deer_files)) 13 | ) 14 | 15 | Enum.reduce(minimal_records, %{}, fn dr, acc_map -> 16 | {total_files, total_kilobytes} = acc_map[dr.subscription_id] || {0, 0} 17 | {dr_files, dr_kilobytes} = deer_files_stats(dr) 18 | 19 | Map.merge(acc_map, %{dr.subscription_id => {total_files + dr_files, total_kilobytes + dr_kilobytes}}) 20 | end) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/deer_storage/shared_files.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.SharedFiles do 2 | import Ecto.Query, warn: false 3 | 4 | alias DeerStorage.Repo 5 | alias DeerStorage.SharedFiles.SharedFile 6 | 7 | def get_file!(subscription_id, uuid, deer_file_id) do 8 | Repo.one!( 9 | available_query() 10 | |> where([sr], sr.id == ^uuid) 11 | |> where([sr], sr.subscription_id == ^subscription_id) 12 | |> where([sr], sr.deer_file_id == ^deer_file_id) 13 | ) 14 | end 15 | 16 | def create_file!(subscription_id, user_id, deer_record_id, deer_file_id) do 17 | # TODO: track limits: user shared records per day e.g. 100 18 | Repo.insert!( 19 | SharedFile.changeset(%SharedFile{}, %{ 20 | subscription_id: subscription_id, 21 | created_by_user_id: user_id, 22 | deer_record_id: deer_record_id, 23 | deer_file_id: deer_file_id})) 24 | end 25 | 26 | def delete_all_by_deer_record_id!(subscription_id, deer_record_id) do 27 | SharedFile 28 | |> where([sr], sr.subscription_id == ^subscription_id) 29 | |> where([sr], sr.deer_record_id == ^deer_record_id) 30 | |> Repo.delete_all() 31 | end 32 | 33 | def delete_outdated!, do: Repo.delete_all(outdated_query()) 34 | 35 | defp available_query, do: from sr in SharedFile, where: ^DateTime.utc_now < sr.expires_on 36 | defp outdated_query, do: from sr in SharedFile, where: ^DateTime.utc_now >= sr.expires_on 37 | end 38 | -------------------------------------------------------------------------------- /lib/deer_storage/shared_files/shared_file.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.SharedFiles.SharedFile do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias DeerStorage.Users.User 6 | alias DeerStorage.Subscriptions.Subscription 7 | alias DeerStorage.DeerRecords.DeerRecord 8 | 9 | @primary_key {:id, :binary_id, read_after_writes: true} 10 | schema "shared_files" do 11 | belongs_to :created_by_user, User 12 | belongs_to :deer_record, DeerRecord 13 | belongs_to :subscription, Subscription 14 | field :expires_on, :utc_datetime 15 | field :deer_file_id, :string 16 | 17 | timestamps(updated_at: false) 18 | end 19 | 20 | @doc false 21 | def changeset(shared_file, attrs) do 22 | ninety_days_from_now = DateTime.truncate(DateTime.add(DateTime.utc_now, 7_776_000, :second), :second) 23 | 24 | shared_file 25 | |> cast(attrs, [:deer_record_id, :subscription_id, :created_by_user_id, :deer_file_id]) 26 | |> cast(%{expires_on: ninety_days_from_now}, [:expires_on]) 27 | |> validate_required([:deer_record_id, :subscription_id, :created_by_user_id, :deer_file_id]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/deer_storage/shared_records.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.SharedRecords do 2 | import Ecto.Query, warn: false 3 | 4 | alias Phoenix.PubSub 5 | alias DeerStorage.Repo 6 | alias DeerStorage.SharedRecords.SharedRecord 7 | 8 | def get_record!(subscription_id, uuid) do 9 | Repo.one!( 10 | available_query() 11 | |> where([sr], sr.id == ^uuid) 12 | |> where([sr], sr.subscription_id == ^subscription_id) 13 | ) 14 | end 15 | 16 | def create_record!(subscription_id, user_id, deer_record_id) do 17 | do_create!(subscription_id, user_id, deer_record_id, false) 18 | end 19 | 20 | def create_record_for_editing!(subscription_id, user_id, deer_record_id) do 21 | do_create!(subscription_id, user_id, deer_record_id, true) 22 | end 23 | 24 | def delete_all_by_deer_record_id!(subscription_id, deer_record_id) do 25 | SharedRecord 26 | |> where([sr], sr.subscription_id == ^subscription_id) 27 | |> where([sr], sr.deer_record_id == ^deer_record_id) 28 | |> Repo.delete_all() 29 | 30 | PubSub.broadcast DeerStorage.PubSub, "shared_records_invalidates:#{deer_record_id}", :all_shared_records_invalidated 31 | end 32 | 33 | def delete_outdated!, do: Repo.delete_all(outdated_query()) 34 | 35 | defp do_create!(subscription_id, user_id, deer_record_id, is_editable) do 36 | # TODO: track limits: user shared records per day e.g. 100 37 | Repo.insert!( 38 | SharedRecord.changeset(%SharedRecord{}, %{ 39 | subscription_id: subscription_id, 40 | created_by_user_id: user_id, 41 | deer_record_id: deer_record_id, 42 | is_editable: is_editable})) 43 | end 44 | 45 | defp available_query, do: from sr in SharedRecord, where: ^DateTime.utc_now < sr.expires_on 46 | defp outdated_query, do: from sr in SharedRecord, where: ^DateTime.utc_now >= sr.expires_on 47 | end 48 | -------------------------------------------------------------------------------- /lib/deer_storage/shared_records/shared_record.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.SharedRecords.SharedRecord do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias DeerStorage.Users.User 6 | alias DeerStorage.Subscriptions.Subscription 7 | alias DeerStorage.DeerRecords.DeerRecord 8 | 9 | @primary_key {:id, :binary_id, read_after_writes: true} 10 | schema "shared_records" do 11 | belongs_to :created_by_user, User 12 | belongs_to :deer_record, DeerRecord 13 | belongs_to :subscription, Subscription 14 | field :expires_on, :utc_datetime 15 | field :is_editable, :boolean 16 | 17 | timestamps(updated_at: false) 18 | end 19 | 20 | @doc false 21 | def changeset(shared_record, attrs) do 22 | ninety_days_from_now = DateTime.truncate(DateTime.add(DateTime.utc_now, 7_776_000, :second), :second) 23 | 24 | shared_record 25 | |> cast(attrs, [:deer_record_id, :subscription_id, :created_by_user_id, :is_editable]) 26 | |> cast(%{expires_on: ninety_days_from_now}, [:expires_on]) 27 | |> validate_required([:deer_record_id, :subscription_id, :created_by_user_id]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/deer_storage/subscriptions/_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Subscriptions.Helpers do 2 | def deer_tables_to_attrs(deer_tables) do 3 | Enum.map(deer_tables, fn dt -> %{ 4 | id: dt.id, 5 | name: dt.name, 6 | deer_columns: Enum.map(dt.deer_columns, fn dc -> 7 | %{ 8 | id: dc.id, 9 | name: dc.name} 10 | end) 11 | } end) 12 | end 13 | 14 | def overwrite_table_with_attrs(deer_tables, table_id, attrs) do 15 | Enum.map(deer_tables, fn dt -> 16 | case dt.id == table_id do 17 | true -> Map.merge(dt, attrs) 18 | false -> dt 19 | end 20 | end) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/deer_storage/subscriptions/deer_column.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Subscriptions.DeerColumn do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | embedded_schema do 6 | field :name, :string 7 | end 8 | 9 | @doc false 10 | def changeset(deer_column, attrs) do 11 | deer_column 12 | |> cast(attrs, [:name]) 13 | |> validate_required(:name) 14 | |> validate_length(:name, min: 3) 15 | |> validate_length(:name, max: 50) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/deer_storage/subscriptions/deer_table.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Subscriptions.DeerTable do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias DeerStorage.Subscriptions.DeerColumn 6 | 7 | embedded_schema do 8 | field :name, :string 9 | embeds_many :deer_columns, DeerColumn, on_replace: :delete 10 | end 11 | 12 | @doc false 13 | def ensure_no_columns_are_missing_changeset(deer_table_changeset, attrs, [subscription: subscription]) do 14 | changeset = changeset(deer_table_changeset, attrs) 15 | id = changeset.data.id # this should not be fetch_field 16 | 17 | case Enum.find(subscription.deer_tables, fn dt -> id == dt.id end) do 18 | nil -> add_error(changeset, :deer_columns, "missing") 19 | table_before -> 20 | columns_ids_before = Enum.map(table_before.deer_columns, fn dc -> dc.id end) 21 | columns_ids_proposed = Enum.map(fetch_field!(changeset, :deer_columns), fn dc -> dc.id end) 22 | 23 | case columns_ids_before -- columns_ids_proposed do 24 | [] -> 25 | changeset 26 | |> validate_length(:deer_columns, max: subscription.deer_columns_per_table_limit) 27 | _ -> add_error(changeset, :deer_columns, "missing") 28 | end 29 | end 30 | end 31 | 32 | @doc false 33 | def changeset(changeset, attrs) do 34 | changeset 35 | |> cast(attrs, [:name]) 36 | |> validate_required(:name) 37 | |> validate_length(:name, min: 3) 38 | |> validate_length(:name, max: 50) 39 | |> cast_embed(:deer_columns) 40 | end 41 | 42 | def add_empty_column(changeset), do: changeset(changeset, %{deer_columns: deer_columns_attrs(changeset) ++ [%{name: ""}]}) 43 | 44 | def move_column_to_index(changeset, current_index, new_index) do 45 | deer_columns = deer_columns_attrs(changeset) 46 | new_index = if new_index > (length(deer_columns) - 1), do: 0, else: new_index 47 | 48 | new_deer_columns = deer_columns 49 | |> List.delete_at(current_index) 50 | |> List.insert_at(new_index, Enum.at(deer_columns, current_index)) 51 | 52 | changeset(changeset, %{deer_columns: new_deer_columns}) 53 | end 54 | 55 | defp deer_columns_attrs(changeset), do: fetch_field!(changeset, :deer_columns) |> Enum.map(&Map.from_struct/1) 56 | end 57 | -------------------------------------------------------------------------------- /lib/deer_storage/user_available_subscription_links/user_available_subscription_link.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.UserAvailableSubscriptionLinks.UserAvailableSubscriptionLink do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias DeerStorage.Users.User 6 | alias DeerStorage.Subscriptions.Subscription 7 | 8 | schema "user_available_subscription_links" do 9 | belongs_to :user, User 10 | belongs_to :subscription, Subscription 11 | field :permission_to_manage_users, :boolean, default: false 12 | 13 | timestamps() 14 | end 15 | 16 | def changeset(user_available_subscription_link, params \\ %{}) do 17 | user_available_subscription_link 18 | |> cast(params, [:user_id, :subscription_id, :permission_to_manage_users]) 19 | |> foreign_key_constraint(:subscription_id) 20 | |> foreign_key_constraint(:user_id) 21 | |> unique_constraint([:user_id, :subscription_id], 22 | name: :user_id_available_subscription_id_unique_index, 23 | message: "Already exists" 24 | ) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/deer_storage_web.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb 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 DeerStorageWeb, :controller 9 | use DeerStorageWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: DeerStorageWeb 23 | 24 | import Plug.Conn 25 | import DeerStorageWeb.Gettext 26 | import Phoenix.LiveView.Controller 27 | 28 | alias DeerStorageWeb.Router.Helpers, as: Routes 29 | end 30 | end 31 | 32 | def view do 33 | quote do 34 | use Phoenix.View, 35 | root: "lib/deer_storage_web/templates", 36 | namespace: DeerStorageWeb 37 | 38 | # Import convenience functions from controllers 39 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1, get_csrf_token: 0] 40 | 41 | # Use all HTML functionality (forms, tags, etc) 42 | use Phoenix.HTML 43 | 44 | import DeerStorageWeb.ErrorHelpers 45 | import DeerStorageWeb.Gettext 46 | alias DeerStorageWeb.Router.Helpers, as: Routes 47 | 48 | import DeerStorageWeb.DateHelpers 49 | 50 | import Phoenix.LiveView.Helpers, 51 | only: [live_component: 2, live_component: 3, live_component: 4, live_redirect: 2, live_render: 3] 52 | end 53 | end 54 | 55 | def router do 56 | quote do 57 | use Phoenix.Router 58 | import Plug.Conn 59 | import Phoenix.Controller 60 | 61 | import Phoenix.LiveView.Router 62 | end 63 | end 64 | 65 | def channel do 66 | quote do 67 | use Phoenix.Channel 68 | import DeerStorageWeb.Gettext 69 | end 70 | end 71 | 72 | def mailer_view do 73 | quote do 74 | use Phoenix.View, root: "lib/deer_storage_web/templates", namespace: DeerStorageWeb 75 | use Phoenix.HTML 76 | end 77 | end 78 | 79 | @doc """ 80 | When used, dispatch to the appropriate controller/view/etc. 81 | """ 82 | defmacro __using__(which) when is_atom(which) do 83 | apply(__MODULE__, which, []) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/_confirmation_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ControllerHelpers.ConfirmationHelpers do 2 | alias DeerStorageWeb.Router.Helpers, as: Routes 3 | alias PowEmailConfirmation.Phoenix.Mailer 4 | 5 | def send_confirmation_email(user, conn) do 6 | url = confirmation_url(conn, user.email_confirmation_token) 7 | unconfirmed_user = %{user | email: user.unconfirmed_email || user.email} 8 | email = Mailer.email_confirmation(conn, unconfirmed_user, url) 9 | 10 | Pow.Phoenix.Mailer.deliver(conn, email) 11 | end 12 | 13 | defp confirmation_url(conn, token) do 14 | Routes.confirmation_path(conn, :show, token) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/_feature_flags_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ControllerHelpers.FeatureFlagsHelpers do 2 | import Phoenix.Controller, only: [text: 2] 3 | 4 | def wrap_feature_endpoint(true = _flag_is_enabled, _conn, fun), do: fun.() 5 | def wrap_feature_endpoint(false = _flag_is_disabled, conn, _fun) do 6 | text(conn, "Feature is disabled") 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/_file_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ControllerHelpers.FileHelpers do 2 | import Phoenix.Controller, only: [send_download: 3] 3 | import Plug.Conn, only: [put_resp_header: 3] 4 | 5 | def send_download_with_range_headers(conn, file_path, deer_file) do 6 | conn 7 | |> put_resp_header("Content-Type", deer_file.mimetype) 8 | |> put_resp_header("Accept-Ranges", "bytes") 9 | |> send_download({:file, file_path}, filename: deer_file.original_filename) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/_subscription_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ControllerHelpers.SubscriptionHelpers do 2 | use DeerStorageWeb, :controller 3 | 4 | def verify_if_subscription_is_expired(%{assigns: %{current_subscription_is_expired: false}} = conn, _opts), do: conn 5 | def verify_if_subscription_is_expired(%{assigns: %{current_subscription_is_expired: true}} = conn, _opts) do 6 | conn 7 | |> put_flash(:error, gettext("Your database expired")) 8 | |> redirect(to: Routes.registration_path(conn, :edit)) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/_user_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ControllerHelpers.UserHelpers do 2 | use DeerStorageWeb, :controller 3 | 4 | def redirect_to_dashboard(%{assigns: %{current_user: %{role: "admin"}}} = conn, nil) do 5 | conn |> redirect(to: Routes.admin_live_path(conn, DeerStorageWeb.Admin.DashboardLive.Index)) 6 | end 7 | def redirect_to_dashboard(conn, _), do: conn |> redirect(to: Routes.live_path(conn, DeerStorageWeb.DeerDashboardLive.Index)) 8 | end 9 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/admin/user_subscription_link_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.Admin.UserSubscriptionLinkController do 2 | use DeerStorageWeb, :controller 3 | 4 | alias DeerStorage.Users 5 | 6 | def reset(conn, %{"user_id" => user_id}) do 7 | user = Users.get_user!(user_id) 8 | 9 | Users.update_last_used_subscription_id!(user, nil) 10 | 11 | conn 12 | |> put_flash(:info, gettext("User current database has been reset")) 13 | |> redirect(to: Routes.admin_user_path(conn, :show, user)) 14 | end 15 | 16 | def make_current(conn, %{"user_id" => user_id, "subscription_id" => subscription_id}) do 17 | user = Users.get_user!(user_id) 18 | subscription_id = subscription_id |> String.to_integer 19 | 20 | Users.update_last_used_subscription_id!(user, subscription_id) 21 | 22 | conn 23 | |> put_flash(:info, gettext("User current database has been changed")) 24 | |> redirect(to: Routes.admin_user_path(conn, :show, user)) 25 | end 26 | 27 | def delete(conn, %{"id" => user_id, "subscription_id" => subscription_id} = params), do: delete_user_subscription_link(conn, params, user_id, subscription_id) 28 | def delete(conn, %{"user_id" => user_id, "id" => subscription_id} = params), do: delete_user_subscription_link(conn, params, user_id, subscription_id) 29 | 30 | def create(conn, %{"user_id" => user_id, "subscription_id" => subscription_id} = params) do 31 | user = Users.get_user!(user_id) 32 | subscription_id = subscription_id |> String.to_integer 33 | 34 | # TODO: validate presence of user_id and subscription_id 35 | 36 | Users.insert_subscription_link_and_maybe_change_last_used_subscription_id(user, subscription_id) 37 | 38 | conn 39 | |> put_flash(:info, gettext("User has been connected to this database")) 40 | |> redirect(to: path_to_redirect(conn, params)) 41 | end 42 | 43 | defp delete_user_subscription_link(conn, params, user_id, subscription_id) do 44 | user = Users.get_user!(user_id) 45 | subscription_id = subscription_id |> String.to_integer 46 | 47 | Users.remove_subscription_link_and_maybe_change_last_used_subscription_id(user, subscription_id) 48 | 49 | conn 50 | |> put_flash(:info, gettext("User has been disconnected from this database")) 51 | |> redirect(to: path_to_redirect(conn, params)) 52 | end 53 | 54 | defp path_to_redirect(conn, %{"subscription_id" => id, "redirect_back_to" => "subscription"}), do: Routes.admin_subscription_path(conn, :show, id) 55 | defp path_to_redirect(conn, %{"user_id" => id, "redirect_back_to" => "user"}), do: Routes.admin_user_path(conn, :show, id) 56 | end 57 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/change_language_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ChangeLanguageController do 2 | use DeerStorageWeb, :controller 3 | 4 | def change_language(conn, %{"locale" => locale}) do 5 | known_locales = Gettext.known_locales(DeerStorageWeb.Gettext) 6 | 7 | if Enum.member?(known_locales, locale), 8 | do: put_locale_to_session_and_redirect_back(conn, locale), 9 | else: raise("unsupported locale requested") 10 | end 11 | 12 | defp put_locale_to_session_and_redirect_back(conn, locale) do 13 | conn 14 | |> Plug.Conn.put_session(:locale, locale) 15 | |> redirect(to: NavigationHistory.last_path(conn, default: "/")) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ConfirmationController do 2 | use DeerStorageWeb, :controller 3 | 4 | def show(conn, %{"id" => token}) do 5 | case PowEmailConfirmation.Plug.confirm_email(conn, token) do # TODO fix deprecation warning 6 | {:ok, _user, conn} -> 7 | conn 8 | |> put_flash(:info, gettext("E-mail has been confirmed")) 9 | |> redirect(to: redirect_to(conn)) 10 | {:error, _changeset, conn} -> 11 | conn 12 | |> put_flash(:error, gettext("Failed to confirm e-mail")) 13 | |> redirect(to: redirect_to(conn)) 14 | end 15 | end 16 | 17 | 18 | defp redirect_to(conn) do 19 | case Pow.Plug.current_user(conn) do 20 | nil -> Routes.session_path(conn, :new) 21 | _user -> Routes.registration_path(conn, :edit) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/deer_files_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.DeerFilesController do 2 | use DeerStorageWeb, :controller 3 | 4 | import DeerStorageWeb.LiveHelpers, only: [is_expired?: 1] 5 | import DeerStorage.DeerRecords, only: [get_record!: 1, ensure_deer_file_exists_in_record!: 2] 6 | import DeerStorage.Users, only: [ensure_user_subscription_link!: 2] 7 | import DeerStorageWeb.ControllerHelpers.FileHelpers 8 | 9 | def download_record(%Plug.Conn{assigns: %{current_user: user}} = conn, %{"record_id" => record_id, "file_id" => file_id}) do 10 | record = get_record!(record_id) |> DeerStorage.Repo.preload(:subscription) 11 | subscription_id = record.subscription.id 12 | 13 | if is_expired?(record.subscription), do: raise "Subscription is expired" 14 | ensure_user_subscription_link!(user.id, subscription_id) 15 | 16 | deer_file = ensure_deer_file_exists_in_record!(record, file_id) 17 | 18 | file_path = File.cwd! <> "/uploaded_files/#{subscription_id}/#{record.deer_table_id}/#{record.id}/#{deer_file.id}" 19 | 20 | send_download_with_range_headers(conn, file_path, deer_file) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.PageController do 2 | use DeerStorageWeb, :controller 3 | 4 | import DeerStorageWeb.ControllerHelpers.UserHelpers, only: [redirect_to_dashboard: 2] 5 | 6 | def index(conn, _params) do 7 | render(conn, "index.html") 8 | rescue Phoenix.Template.UndefinedError -> 9 | redirect_to_dashboard_or_new_session(conn) 10 | end 11 | 12 | defp redirect_to_dashboard_or_new_session(conn) do 13 | case Pow.Plug.current_user(conn) do 14 | %{last_used_subscription_id: id} -> 15 | redirect_to_dashboard(conn, id) 16 | _ -> 17 | redirect(conn, to: Routes.session_path(conn, :new)) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/deer_storage_web/controllers/shared_record_files_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.SharedRecordFilesController do 2 | use DeerStorageWeb, :controller 3 | 4 | alias DeerStorage.SharedRecords 5 | alias DeerStorage.SharedFiles 6 | import DeerStorageWeb.LiveHelpers, only: [is_expired?: 1] 7 | import DeerStorage.DeerRecords, only: [ensure_deer_file_exists_in_record!: 2] 8 | import DeerStorageWeb.ControllerHelpers.FileHelpers 9 | 10 | def download_file_from_shared_file(conn, %{"subscription_id" => subscription_id, "shared_file_id" => shared_file_id, "file_id" => file_id}) do 11 | shared_file = SharedFiles.get_file!(subscription_id, shared_file_id, file_id) |> DeerStorage.Repo.preload([:deer_record, :subscription]) 12 | deer_record = shared_file.deer_record 13 | 14 | if is_expired?(shared_file.subscription), do: raise "Subscription is expired" 15 | deer_file = ensure_deer_file_exists_in_record!(deer_record, file_id) # TODO OR show error 16 | 17 | file_path = File.cwd! <> "/uploaded_files/#{subscription_id}/#{deer_record.deer_table_id}/#{deer_record.id}/#{deer_file.id}" 18 | 19 | send_download_with_range_headers(conn, file_path, deer_file) 20 | 21 | rescue _ -> {:noreply, conn |> put_flash(:error, "Could not find file.") |> redirect(to: "/")} 22 | end 23 | 24 | def download_file_from_shared_record(conn, %{"subscription_id" => subscription_id, "shared_record_id" => shared_record_id, "file_id" => file_id}) do 25 | shared_record = SharedRecords.get_record!(subscription_id, shared_record_id) |> DeerStorage.Repo.preload([:deer_record, :subscription]) 26 | deer_record = shared_record.deer_record 27 | if is_expired?(shared_record.subscription), do: raise "Subscription is expired" 28 | 29 | deer_file = ensure_deer_file_exists_in_record!(deer_record, file_id) 30 | 31 | file_path = File.cwd! <> "/uploaded_files/#{subscription_id}/#{deer_record.deer_table_id}/#{deer_record.id}/#{deer_file.id}" 32 | 33 | send_download_with_range_headers(conn, file_path, deer_file) 34 | 35 | rescue _ -> {:noreply, conn |> put_flash(:error, "Could not find record.") |> redirect(to: "/")} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/deer_storage_web/date_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.DateHelpers do 2 | use Timex 3 | alias DeerStorage.Users.User 4 | 5 | @default_time_zone "Europe/Warsaw" 6 | 7 | def dt(_, nil), do: "" 8 | 9 | def dt(%User{time_zone: time_zone, locale: locale}, datetime) do 10 | convert_datetime(datetime, time_zone, locale) 11 | end 12 | 13 | def dt(%Plug.Conn{assigns: %{current_user: %User{time_zone: time_zone}, locale: locale}}, datetime) do 14 | convert_datetime(datetime, time_zone, locale) 15 | end 16 | 17 | def dt(%Plug.Conn{assigns: %{locale: locale}}, datetime) do 18 | convert_datetime(datetime, @default_time_zone, locale) 19 | end 20 | 21 | defp convert_datetime(datetime, time_zone, locale) do 22 | format = "{D} {Mfull} {YYYY} {h24}:{m}" 23 | 24 | {:ok, result} = Timex.lformat(Timezone.convert(datetime, time_zone), format, locale) 25 | 26 | result 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/deer_storage_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :deer_storage 3 | 4 | @session_options store: :cookie, key: "_session_key", signing_salt: "KLHKFqia" 5 | 6 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 7 | 8 | # Serve at "/" the static files from "priv/static" directory. 9 | # 10 | # You should set gzip to true if you are running phx.digest 11 | # when deploying your static files in production. 12 | plug Plug.Static, 13 | at: "/", 14 | from: :deer_storage, 15 | gzip: true, 16 | only: ~w(css fonts images js favicon.ico robots.txt ViewerJS) 17 | 18 | # Code reloading can be explicitly enabled under the 19 | # :code_reloader configuration of your endpoint. 20 | if code_reloading? do 21 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 22 | plug Phoenix.LiveReloader 23 | plug Phoenix.CodeReloader 24 | end 25 | 26 | plug Phoenix.LiveDashboard.RequestLogger, param_key: "request_logger", cookie_key: "request_logger" 27 | 28 | plug Plug.RequestId 29 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 30 | 31 | plug Plug.Parsers, 32 | parsers: [:urlencoded, {:multipart, length: 268_435_456}, :json], # 256MB 33 | pass: ["*/*"], 34 | json_decoder: Phoenix.json_library() 35 | 36 | plug Plug.MethodOverride 37 | plug Plug.Head 38 | 39 | # The session will be stored in the cookie and signed, 40 | # this means its contents can be read but not tampered with. 41 | # Set :encryption_salt if you would also like to encrypt it. 42 | plug Plug.Session, @session_options 43 | 44 | plug Pow.Plug.Session, otp_app: :deer_storage 45 | plug PowPersistentSession.Plug.Cookie 46 | 47 | plug DeerStorageWeb.Router 48 | end 49 | -------------------------------------------------------------------------------- /lib/deer_storage_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import DeerStorageWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :deer_storage 24 | end 25 | -------------------------------------------------------------------------------- /lib/deer_storage_web/live/admin_dashboard_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.Admin.DashboardLive.Index do 2 | use Phoenix.LiveView 3 | alias Phoenix.PubSub 4 | 5 | import DeerStorage.Users.UserSessionUtils, only: [get_live_user: 2] 6 | 7 | alias DeerStorage.Users 8 | alias DeerStorage.Subscriptions 9 | 10 | def mount(%{}, %{"deer_storage_auth" => token, "current_user_id" => current_user_id} = session, socket) do 11 | socket = case connected?(socket) do 12 | true -> 13 | get_live_user(socket, session).locale |> Gettext.put_locale() 14 | 15 | # TODO: Renewing tokens 16 | PubSub.subscribe(DeerStorage.PubSub, "Users") 17 | PubSub.subscribe(DeerStorage.PubSub, "session_#{token}") 18 | PubSub.subscribe(DeerStorage.PubSub, "user_#{current_user_id}") 19 | fetch(socket) 20 | false -> assign(socket, 21 | users_count: "", 22 | subscriptions_count: "", 23 | disk_data: [], 24 | root_disk_percentage: 0, 25 | root_disk_total_size: 0 26 | ) 27 | end 28 | 29 | {:ok, assign(socket, token: token)} 30 | end 31 | 32 | def render(assigns), do: DeerStorageWeb.Admin.DashboardView.render("index.html", assigns) 33 | 34 | defp fetch(socket) do 35 | users = Users.total_count 36 | subscriptions = Subscriptions.total_count 37 | {_, root_disk_total_size, root_disk_percentage} = :disksup.get_disk_data |> Enum.find(fn {mountpoint, _size, _perc} -> mountpoint == '/' end) 38 | 39 | socket |> assign(users_count: users, 40 | subscriptions_count: subscriptions, 41 | root_disk_percentage: root_disk_percentage, 42 | root_disk_total_size: root_disk_total_size 43 | ) 44 | end 45 | 46 | def handle_info({[:user | _], _}, socket), do: {:noreply, socket |> fetch} 47 | 48 | def handle_info(:logout, socket), do: {:noreply, push_redirect(socket, to: "/")} 49 | 50 | # TODO: renew tokens 51 | end 52 | -------------------------------------------------------------------------------- /lib/deer_storage_web/live/deer_dashboard_live/deer_table_edit_component.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.DeerDashboardLive.DeerTableEditComponent do 2 | use Phoenix.LiveComponent 3 | 4 | import DeerStorageWeb.Gettext 5 | import Phoenix.HTML.Form 6 | 7 | import DeerStorageWeb.ErrorHelpers, only: [error_tag: 2] 8 | 9 | def update(%{changeset: changeset, table: %{id: table_id}, columns_per_table_limit: columns_per_table_limit}, socket) do 10 | {:ok, assign( 11 | socket, 12 | table_id: table_id, 13 | changeset: changeset, 14 | columns_per_table_limit: columns_per_table_limit, 15 | columns_count: length(Ecto.Changeset.fetch_field!(changeset, :deer_columns)))} 16 | end 17 | 18 | def render(assigns) do 19 | ~L""" 20 |
21 | <%= form_for @changeset, "#", [phx_change: :validate_table_edit, phx_submit: :save_table_edit, autocomplete: "off"], fn f -> %> 22 | <%= hidden_input f, :id, value: @table_id %> 23 | 24 | <%= label f, gettext("Name"), class: "label field-label" %> 25 | <%= text_input f, :name, class: "input" %> 26 | <%= error_tag f, :name %> 27 | 28 |
29 | 30 | <%= label f, gettext("Columns"), class: "label field-label" %> 31 | 32 | <%= inputs_for f, :deer_columns, fn dc -> %> 33 |
34 |

35 | 36 | 37 |

38 |

39 |

40 | <%= text_input dc, :name, class: "input is-small" %> 41 | <%= error_tag dc, :name %> 42 |
43 |

44 |
45 | <% end %> 46 | 47 | <%= if @columns_count < @columns_per_table_limit do %> 48 | <%= gettext("Add column") %> 49 | (<%= @columns_count %>/<%= @columns_per_table_limit %>) 50 | <% else %> 51 | <%= gettext("You can't add more columns") %> 52 | <% end %> 53 | 54 |

55 | 56 | <%= submit gettext("Save"), class: "button is-success" %> 57 | <%= gettext("Cancel") %> 58 | <% end %> 59 |
60 | """ 61 | end 62 | 63 | end 64 | -------------------------------------------------------------------------------- /lib/deer_storage_web/live/deer_dashboard_live/deer_table_show_component.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.DeerDashboardLive.DeerTableShowComponent do 2 | alias DeerStorageWeb.Router.Helpers, as: Routes 3 | use Phoenix.LiveComponent 4 | 5 | import DeerStorageWeb.Gettext 6 | 7 | def render(%{table: %{id: table_id, name: table_name, deer_columns: deer_columns}, cached_count: cached_count, records_per_table_limit: per_table_limit} = assigns) do 8 | ~L""" 9 |
10 | <%= if @editing_table_id == nil do %> 11 | 12 | <%= live_redirect "#{table_name}", to: Routes.live_path(@socket, DeerStorageWeb.DeerRecordsLive.Index, table_id) %> 13 | 14 | 15 | (<%= cached_count %>/<%= per_table_limit %>) 16 | 17 | <% else %> 18 | <%= table_name %> 19 | (<%= cached_count %>) 20 | <% end %> 21 | 22 |
23 | 24 | <%= for %{name: name} <- deer_columns do %> 25 | <%= name %>
26 | <% end %> 27 | 28 |
29 | 30 | <%= gettext("Edit") %> 31 | <%= if cached_count == 0 do %> 32 | 36 | <%= gettext("Delete") %> 37 | 38 | <% else %> 39 | "> 44 | <%= gettext("Delete") %> 45 | 46 | <% end %> 47 |
48 | """ 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/deer_storage_web/live/deer_records_live/modal/created_shared_record_component.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.DeerRecordsLive.Modal.CreatedSharedRecordComponent do 2 | use Phoenix.LiveComponent 3 | import DeerStorageWeb.Gettext 4 | 5 | def render(assigns) do 6 | ~L""" 7 | 14 | 15 | 40 | """ 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/deer_storage_web/live/deer_records_live/socket_assigns/new_record.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.DeerRecordsLive.Index.SocketAssigns.NewRecord do 2 | alias DeerStorage.DeerRecords.DeerRecord 3 | import Phoenix.LiveView, only: [assign: 2] 4 | 5 | import DeerStorageWeb.DeerRecordsLive.Index.SocketAssigns.Helpers, only: [atomize_and_merge_table_id_to_attrs: 2] 6 | import DeerStorage.DeerRecords, only: [change_record: 3, check_limits_and_create_record: 3] 7 | 8 | def assign_opened_new_record_modal(%{assigns: %{current_subscription: %{deer_tables: deer_tables} = subscription, table_id: table_id}} = socket) do 9 | deer_columns = Enum.find(deer_tables, fn table -> table.id == table_id end).deer_columns 10 | deer_fields_attrs = Enum.map(deer_columns, fn %{id: column_id} -> %{deer_column_id: column_id, content: ""} end) 11 | 12 | assign(socket, new_record: change_record(subscription, %DeerRecord{}, %{deer_table_id: table_id, deer_fields: deer_fields_attrs})) 13 | end 14 | 15 | def assign_created_record(%{assigns: %{current_subscription: subscription, table_id: table_id, cached_count: cached_count}} = socket, attrs) do 16 | atomized_attrs = atomize_and_merge_table_id_to_attrs(attrs, table_id) 17 | 18 | case check_limits_and_create_record(subscription, atomized_attrs, cached_count) do 19 | {:ok, _} -> assign(socket, new_record: nil, query: []) 20 | {:error, %Ecto.Changeset{} = changeset} -> assign(socket, new_record: changeset) 21 | end 22 | end 23 | 24 | def assign_new_record(%{assigns: %{current_subscription: subscription, table_id: table_id, new_record: new_record}} = socket, attrs) do 25 | socket |> assign(new_record: change_record(subscription, new_record, atomize_and_merge_table_id_to_attrs(attrs, table_id))) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/deer_storage_web/live_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.LiveHelpers do 2 | def list_new_table_ids(old_deer_tables, new_deer_tables) do 3 | old_ids = Enum.map(old_deer_tables, fn dt -> dt.id end) 4 | new_ids = Enum.map(new_deer_tables, fn dt -> dt.id end) 5 | 6 | new_ids -- old_ids 7 | end 8 | 9 | def cached_counts(deer_tables) do 10 | Enum.reduce(deer_tables, %{}, fn %{id: id}, acc -> 11 | Map.merge(acc, %{id => DeerCache.RecordsCountsCache.fetch_count(id)}) 12 | end) 13 | end 14 | 15 | def is_expired?(%{expires_on: date}), do: Date.diff(date, Date.utc_today) < 1 16 | 17 | def keys_to_atoms(%{} = map) do 18 | Enum.reduce(map, %{}, fn 19 | {key, value}, acc when is_atom(key) -> Map.put(acc, key, value) 20 | {key, value}, acc when is_binary(key) -> Map.put(acc, String.to_existing_atom(key), value) 21 | end) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/deer_storage_web/metrics_storage.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.MetricsStorage do 2 | use GenServer 3 | 4 | @history_buffer_size 50 5 | 6 | def metrics_history(metric) do 7 | GenServer.call(__MODULE__, {:data, metric}) 8 | end 9 | 10 | def start_link(args) do 11 | GenServer.start_link(__MODULE__, args, name: __MODULE__) 12 | end 13 | 14 | @impl true 15 | def init(metrics) do 16 | Process.flag(:trap_exit, true) 17 | 18 | metric_histories_map = 19 | metrics 20 | |> Enum.map(fn metric -> 21 | attach_handler(metric) 22 | {metric, CircularBuffer.new(@history_buffer_size)} 23 | end) 24 | |> Map.new() 25 | 26 | {:ok, metric_histories_map} 27 | end 28 | 29 | @impl true 30 | def terminate(_, metrics) do 31 | for metric <- metrics do 32 | :telemetry.detach({__MODULE__, metric, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | defp attach_handler(%{event_name: name_list} = metric) do 39 | :telemetry.attach( 40 | {__MODULE__, metric, self()}, 41 | name_list, 42 | &__MODULE__.handle_event/4, 43 | metric 44 | ) 45 | end 46 | 47 | def handle_event(_event_name, data, metadata, metric) do 48 | if data = Phoenix.LiveDashboard.extract_datapoint_for_metric(metric, data, metadata) do 49 | GenServer.cast(__MODULE__, {:telemetry_metric, data, metric}) 50 | end 51 | end 52 | 53 | @impl true 54 | def handle_cast({:telemetry_metric, data, metric}, state) do 55 | {:noreply, update_in(state[metric], &CircularBuffer.insert(&1, data))} 56 | end 57 | 58 | @impl true 59 | def handle_call({:data, metric}, _from, state) do 60 | if history = state[metric] do 61 | {:reply, CircularBuffer.to_list(history), state} 62 | else 63 | {:reply, [], state} 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/deer_storage_web/plugs/auth_error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.AuthErrorHandler do 2 | use DeerStorageWeb, :controller 3 | alias Plug.Conn 4 | 5 | @spec call(Conn.t(), atom()) :: Conn.t() 6 | def call(conn, :not_authenticated) do 7 | conn 8 | |> put_flash(:error, gettext("You've to be authenticated first")) 9 | |> redirect(to: Routes.session_path(conn, :new)) 10 | end 11 | 12 | @spec call(Conn.t(), atom()) :: Conn.t() 13 | def call(conn, :already_authenticated) do 14 | conn 15 | |> put_flash(:error, gettext("You're already authenticated")) 16 | |> redirect(to: Routes.page_path(conn, :index)) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/deer_storage_web/plugs/ensure_role_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.EnsureRolePlug do 2 | @moduledoc """ 3 | This plug ensures that a user has a particular role. 4 | 5 | ## Example 6 | 7 | plug DeerStorageWeb.EnsureRolePlug, [:user, :admin] 8 | 9 | plug DeerStorageWeb.EnsureRolePlug, :admin 10 | 11 | plug DeerStorageWeb.EnsureRolePlug, ~w(user admin)a 12 | """ 13 | import Plug.Conn, only: [halt: 1] 14 | 15 | alias Phoenix.Controller 16 | alias Plug.Conn 17 | alias Pow.Plug 18 | 19 | @doc false 20 | @spec init(any()) :: any() 21 | def init(config), do: config 22 | 23 | @doc false 24 | @spec call(Conn.t(), atom()) :: Conn.t() 25 | def call(conn, roles) do 26 | conn 27 | |> Plug.current_user() 28 | |> has_role?(roles) 29 | |> maybe_halt(conn) 30 | end 31 | 32 | defp has_role?(nil, _roles), do: false 33 | defp has_role?(user, roles) when is_list(roles), do: Enum.any?(roles, &has_role?(user, &1)) 34 | defp has_role?(user, role) when is_atom(role), do: has_role?(user, Atom.to_string(role)) 35 | defp has_role?(%{role: role}, role), do: true 36 | defp has_role?(_user, _role), do: false 37 | 38 | defp maybe_halt(true, conn), do: conn 39 | defp maybe_halt(_any, conn) do 40 | conn 41 | # |> Controller.put_flash(:error, "Unauthorized access") skip due to liveview 42 | |> Controller.redirect(to: DeerStorageWeb.Router.Helpers.live_path(conn, DeerStorageWeb.DeerDashboardLive.Index)) 43 | |> halt() 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/deer_storage_web/plugs/get_current_subscription_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.GetCurrentSubscriptionPlug do 2 | import Plug.Conn, only: [assign: 3] 3 | 4 | alias DeerStorage.{Subscriptions.Subscription, Repo} 5 | 6 | def init(_opts), do: nil 7 | 8 | def call(%Plug.Conn{assigns: %{current_user: nil}} = conn, _opts), do: conn 9 | def call(%{private: %{phoenix_live_view: {_, live_view_opts}}} = conn, _opts) do 10 | case Keyword.fetch(live_view_opts, :layout) do 11 | {:ok, {_, "without_navigation.html"}} -> conn 12 | _ -> conn |> assign_subscription 13 | end 14 | end 15 | 16 | def call(conn, _opts), do: assign_subscription(conn) 17 | 18 | defp assign_subscription(%{private: %{plug_session: %{"current_subscription_id" => nil}}} = conn), do: assign(conn, :current_subscription, nil) 19 | defp assign_subscription(%{private: %{plug_session: %{"current_subscription_id" => id}}} = conn) do 20 | subscription = Repo.get(Subscription, id) 21 | is_expired = Date.diff(subscription.expires_on, Date.utc_today) < 1 22 | 23 | conn 24 | |> assign(:current_subscription, subscription) 25 | |> assign(:current_subscription_is_expired, is_expired) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/deer_storage_web/plugs/locale_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.LocalePlug do 2 | import Plug.Conn, only: [assign: 3, get_session: 2] 3 | 4 | @default_locale "en" 5 | 6 | def init(_opts), do: nil 7 | 8 | def call(%Plug.Conn{assigns: %{current_user: %DeerStorage.Users.User{locale: locale}}} = conn, _opts), do: put_and_assign_locale(conn, locale) 9 | def call(conn, _opts) do 10 | known_locales = Gettext.known_locales(DeerStorageWeb.Gettext) 11 | session_locale = get_session(conn, "locale") 12 | 13 | if session_locale && Enum.member?(known_locales, session_locale), 14 | do: put_and_assign_locale(conn, session_locale), 15 | else: maybe_put_accept_language_locale(conn, known_locales) 16 | end 17 | 18 | defp maybe_put_accept_language_locale(conn, known_locales) do 19 | locale = Enum.find(extract_accept_language(conn), fn accepted_locale -> Enum.member?(known_locales, accepted_locale) end) 20 | if locale, do: put_and_assign_locale(conn, locale), else: put_and_assign_locale(conn, @default_locale) 21 | end 22 | 23 | defp put_and_assign_locale(conn, locale) do 24 | Gettext.put_locale(DeerStorageWeb.Gettext, locale) 25 | 26 | assign(conn, :locale, locale) 27 | end 28 | 29 | def extract_accept_language(conn) do 30 | case Plug.Conn.get_req_header(conn, "accept-language") do 31 | [value | _] -> 32 | value 33 | |> String.split(",") 34 | |> Enum.map(&parse_language_option/1) 35 | |> Enum.sort(&(&1.quality > &2.quality)) 36 | |> Enum.map(& &1.tag) 37 | |> Enum.reject(&is_nil/1) 38 | |> ensure_language_fallbacks() 39 | 40 | _ -> [] 41 | end 42 | end 43 | 44 | defp parse_language_option(string) do 45 | captures = Regex.named_captures(~r/^\s?(?[\w\-]+)(?:;q=(?[\d\.]+))?$/i, string) 46 | 47 | quality = 48 | case Float.parse(captures["quality"] || "1.0") do 49 | {val, _} -> val 50 | _ -> 1.0 51 | end 52 | 53 | %{tag: captures["tag"], quality: quality} 54 | end 55 | 56 | defp ensure_language_fallbacks(tags) do 57 | Enum.flat_map(tags, fn tag -> 58 | [language | _] = String.split(tag, "-") 59 | if Enum.member?(tags, language), do: [tag], else: [tag, language] 60 | end) 61 | end 62 | 63 | end 64 | -------------------------------------------------------------------------------- /lib/deer_storage_web/pow_mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.PowMailer do 2 | use Bamboo.Mailer, otp_app: :deer_storage 3 | use Pow.Phoenix.Mailer 4 | 5 | import Bamboo.Email 6 | 7 | def cast(%{user: user, subject: subject, text: text, html: html}) do 8 | new_email( 9 | to: user.email, 10 | from: "no-reply@mail.deerstorage.com", 11 | subject: subject, 12 | html_body: html, 13 | text_body: text 14 | ) 15 | end 16 | 17 | def process(email) do 18 | deliver_later(email) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/deer_storage_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | def init(_arg) do 10 | children = [ 11 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}, 12 | {DeerStorageWeb.MetricsStorage, metrics()} 13 | ] 14 | 15 | Supervisor.init(children, strategy: :one_for_one) 16 | end 17 | 18 | def metrics do 19 | [ 20 | # Phoenix Metrics 21 | summary("phoenix.endpoint.stop.duration", 22 | unit: {:native, :millisecond} 23 | ), 24 | summary("phoenix.router_dispatch.stop.duration", 25 | tags: [:route], 26 | unit: {:native, :millisecond} 27 | ), 28 | 29 | # Database Time Metrics 30 | summary("deer_storage.repo.query.total_time", unit: {:native, :millisecond}), 31 | summary("deer_storage.repo.query.decode_time", unit: {:native, :millisecond}), 32 | summary("deer_storage.repo.query.query_time", unit: {:native, :millisecond}), 33 | summary("deer_storage.repo.query.queue_time", unit: {:native, :millisecond}), 34 | summary("deer_storage.repo.query.idle_time", unit: {:native, :millisecond}), 35 | 36 | # VM Metrics 37 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 38 | summary("vm.total_run_queue_lengths.total"), 39 | summary("vm.total_run_queue_lengths.cpu"), 40 | summary("vm.total_run_queue_lengths.io"), 41 | 42 | # LV 43 | summary("phoenix.live_view.mount.stop.duration", 44 | tags: [:view], 45 | tag_values: fn metadata -> 46 | Map.put(metadata, :view, "#{inspect(metadata.socket.view)}") 47 | end, 48 | unit: {:native, :millisecond} 49 | ), 50 | summary("phoenix.live_view.handle_params.stop.duration", 51 | tags: [:view], 52 | tag_values: fn metadata -> 53 | Map.put(metadata, :view, "#{inspect(metadata.socket.view)}") 54 | end, 55 | unit: {:native, :millisecond} 56 | ), 57 | summary("phoenix.live_view.handle_event.stop.duration", 58 | tags: [:view, :event], 59 | tag_values: fn metadata -> 60 | Map.put(metadata, :view, "#{inspect(metadata.socket.view)}") 61 | end, 62 | unit: {:native, :millisecond} 63 | ) 64 | ] 65 | end 66 | 67 | defp periodic_measurements do 68 | [] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/dashboard/index.html.leex: -------------------------------------------------------------------------------- 1 | <% total_megabytes = floor(@root_disk_total_size / 1024) %> 2 | <% used_megabytes = floor(@root_disk_total_size * (@root_disk_percentage / 100) / 1024) %> 3 | 4 |
5 |
6 |
7 |

8 | <%= gettext("Databases") %>: <%= @subscriptions_count %>
9 |

10 |

11 | <%= gettext("Users") %>: <%= @users_count %> 12 |

13 | <%= @root_disk_percentage %>% 14 | 15 | <%= gettext( 16 | "%{used_space} MB used out of %{available_space} MB", 17 | used_space: used_megabytes, 18 | available_space: total_megabytes 19 | ) %> 20 | <%= case @root_disk_percentage do %> 21 | <% perc when perc > 90 -> %><%= perc %>% 22 | <% perc when perc > 80 -> %><%= perc %>% 23 | <% perc when perc > 50 -> %><%= perc %>% 24 | <% perc -> %><%= perc %>% 25 | <% end %> 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/subscription/_header.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Databases") %> 6 |

7 |

8 | <%= gettext("All DeerStorage databases") %> 9 |

10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/subscription/_index_pagination.html.eex: -------------------------------------------------------------------------------- 1 | <%= if @count == @per_page || @page != 1 do %> 2 |
3 | 5 | 6 | 7 | <%= if @page > 1 do %> 8 | <%= @page %> 9 | <% end %> 10 | 11 | 14 | 15 |
16 | <% end %> 17 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/subscription/edit.html.eex: -------------------------------------------------------------------------------- 1 | <%= render "_header.html" %> 2 | 3 | 16 | 17 |
18 |
19 | <%= render "_form.html", Map.put(assigns, :action, Routes.admin_subscription_path(@conn, :update, @subscription)) %> 20 | 21 | <% delete_button = link gettext("Delete this database"), 22 | to: Routes.admin_subscription_path(@conn, :delete, @subscription), 23 | method: :delete, 24 | data: [confirm: gettext("Are you sure?")] %> 25 | 26 |
27 | 28 | <%= if (length @subscription.users) == 0 do %> 29 |
30 | <%= gettext("No users attached.") %> 31 |
32 | <%= delete_button %> 33 |
34 | <% else %> 35 |
36 | <%= gettext("There are users attached to this database. You can still delete it and keep the users without a database.") %> 37 |
38 | <%= delete_button %> 39 |
40 | <% end %> 41 |
42 |
43 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/subscription/new.html.eex: -------------------------------------------------------------------------------- 1 | <%= render "_header.html" %> 2 | 12 | 13 | 14 |
15 |
16 |

17 | <%= gettext("New database") %> 18 |

19 |
20 | 21 | <%= render "_form.html", Map.put(assigns, :action, Routes.admin_subscription_path(@conn, :create)) %> 22 |
23 |
24 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/user/_header.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Users") %> 6 |

7 |

8 | <%= gettext("All DeerStorage users") %> 9 |

10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/user/_index_pagination.html.eex: -------------------------------------------------------------------------------- 1 | <%= if @count == @per_page || @page != 1 do %> 2 |
3 | 5 | 6 | 7 | <%= if @page > 1 do %> 8 | <%= @page %> 9 | <% end %> 10 | 11 | 13 | 14 |
15 | <% end %> 16 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/user/edit.html.eex: -------------------------------------------------------------------------------- 1 | <%= render "_header.html" %> 2 | 3 | 23 | 24 | 25 |
26 |
27 | <%= if @user.role == "admin" do %> 28 |

29 | <%= gettext("You are editing another Administrator's account") %> 30 |

31 |
32 | <% end %> 33 | 34 | <%= render "form.html", Map.put(assigns, :action, Routes.admin_user_path(@conn, :update, @user)) %> 35 | 36 |
37 | 38 |

39 | <%= gettext("Danger zone") %> 40 |

41 | 42 | <%= toggle_admin_button(@conn, @user) %> 43 | 44 |
45 |
46 | 47 | <%= link gettext("Delete account"), 48 | to: Routes.admin_user_path(@conn, :delete, @user), 49 | method: :delete, 50 | data: [confirm: gettext("Are you sure?")], 51 | class: "button is-danger" %> 52 |
53 |
54 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/user/form.html.eex: -------------------------------------------------------------------------------- 1 | <%= form_for @changeset, @action, [autocomplete: "off"], fn f -> %> 2 | <%= if @changeset.action do %> 3 |
4 |

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

5 |
6 | <% end %> 7 | 8 |
9 | <%= label f, :name, gettext("Name and surname"), class: "label" %> 10 | <%= text_input f, :name, class: "input" %> 11 | <%= error_tag f, :name %> 12 |
13 | 14 |
15 | <%= label f, :time_zone, gettext("Time zone"), class: "label" %> 16 | <%= select f, :time_zone, time_zones_select_options(), class: "input use-select2" %> 17 | <%= error_tag f, :email %> 18 |
19 | 20 |
21 | <%= label f, :locale, gettext("Language"), class: "label" %> 22 | <%= select f, :locale, languages_select_options(), class: "input" %> 23 | <%= error_tag f, :locale %> 24 |
25 | 26 |
27 | <%= label f, :email, gettext("E-mail address"), class: "label" %> 28 | <%= text_input f, Pow.Ecto.Schema.user_id_field(@changeset), class: "input" %> 29 | <%= error_tag f, Pow.Ecto.Schema.user_id_field(@changeset) %> 30 |
31 | 32 |
33 | <%= label f, :admin_notes, gettext("Admin notes"), class: "label" %> 34 | <%= textarea f, :admin_notes, class: "textarea" %> 35 | <%= error_tag f, :admin_notes %> 36 |
37 | 38 |
39 | <%= label f, :last_used_subscription_id, gettext("Last used database"), class: "label" %> 40 | <%= select f, :last_used_subscription_id, [], class: "input use-select2" %> 41 | <%= error_tag f, :last_used_subscription_id %> 42 |
43 |
44 | <%= submit gettext("Save"), class: "button is-primary" %> 45 |
46 | <% end %> 47 | 48 | 72 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/admin/user/new.html.eex: -------------------------------------------------------------------------------- 1 | <%= render "_header.html" %> 2 | 3 | 13 | 14 |
15 |
16 |

17 | <%= gettext("New User") %> 18 |

19 |
20 | 21 | <%= render "form.html", Map.put(assigns, :action, Routes.admin_user_path(@conn, :create)) %> 22 |
23 |
24 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/deer_dashboard/_editable_title.html.leex: -------------------------------------------------------------------------------- 1 |

2 | <%= if @editing_subscription_name do %> 3 |
4 |

5 |

6 | 7 | " class="button"/> 8 |
9 | 10 | <%= gettext("Cancel") %> 11 |

12 |
13 | <% else %> 14 | <%= @current_subscription_name %> 15 | <%= gettext("Change name") %> 16 | <% end %> 17 |

18 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/deer_dashboard/_import_csv.html.leex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | <%= gettext("Import table from .csv file") %> 5 | <%= for entry <- @uploads.csv_file.entries do %> 6 |
7 |
8 | <%= entry.client_name %> 9 | 10 | <%= gettext("Cancel") %> 11 | 12 | 13 | <%= entry.progress %>% 14 | 15 | <%= if entry.done? == true do %> 16 | <%= gettext("Ready to import.") %> 17 | <% end %> 18 |
19 |
20 |
21 | <% end %> 22 | 23 |
24 | <%= if Enum.any?(@uploads.csv_file.entries) do %> 25 | 26 | <%= Phoenix.LiveView.Helpers.live_file_input @uploads.csv_file, class: "is-hidden" %> 27 | <% else %> 28 | 37 | <% end %> 38 |
39 |
40 | 41 | <%= if Enum.any?(@csv_importer_messages) do %> 42 |
43 | <%= for msg <- @csv_importer_messages do %> 44 | <%= case msg do %> 45 | <% {:error, msg} -> %><%= msg %> 46 | <% {:info, msg} -> %><%= msg %> 47 | <% end %> 48 |
49 | <% end %> 50 | 51 | <%= gettext("Clear messages") %> 52 | 53 |
54 |
55 | <% end %> 56 |
57 |
58 |
59 |
60 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/deer_record/_editable_prepared_fields.html.leex: -------------------------------------------------------------------------------- 1 | <%= for %{id: column_id, name: column_name, index: index, value: value} <- @prepared_fields do %> 2 |
3 | 4 | 5 |
6 |
7 | 8 | 9 |
10 |
11 |
12 | <% end %> 13 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/deer_record/preview_modal_document.html.leex: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/deer_record/preview_modal_image.html.leex: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/deer_record/preview_modal_video.html.leex: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/invitation/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Join DeerStorage!") %> 6 |

7 |

8 | <%= gettext("You have been invited to join DeerStorage team!") %> 9 |

10 |
11 |
12 |
13 | 14 | 15 |
16 |
17 | <%= form_for @changeset, @action, [as: :user], fn f -> %> 18 | <%= if @changeset.action do %> 19 |
20 |

Oops, something went wrong! Please check the errors below.

21 |
22 | <% end %> 23 | 24 | <%= label f, :email, class: "label" %> 25 | <%= text_input f, :email, class: "input", disabled: true %> 26 | <%= error_tag f, :email %> 27 | 28 | <%= label f, :name, class: "label" %> 29 | <%= text_input f, :name, class: "input" %> 30 | <%= error_tag f, :name %> 31 | 32 | <%= label f, :password, class: "label" %> 33 | <%= password_input f, :password, class: "input" %> 34 | <%= error_tag f, :password %> 35 | 36 | <%= label f, :password_confirmation, class: "label" %> 37 | <%= password_input f, :password_confirmation, class: "input" %> 38 | <%= error_tag f, :password_confirmation %> 39 | 40 |
41 | <%= submit gettext("Submit"), class: "button" %> 42 |
43 | <% end %> 44 |
45 |
46 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/invitation/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Invite user") %> 6 |

7 |

8 | <%= gettext("Sends a link to a desired email allowing them to connect and collaborate with your database") %> 9 |

10 |
11 |
12 |
13 | 14 |
15 |
16 | <%= if mailing_disabled?() do %> 17 |
18 | <%= gettext("Emails are disabled. New users must be confirmed by an administrator.") %> 19 |

20 | <%= gettext("You can still invite users to edit your database after they register. Just ask them for the e-mail used during registration and submit it in the form below.") %> 21 |

22 | <%= gettext("... or you can ask an administrator to connect them to your database.") %> 23 |
24 | <% end %> 25 | 26 | <%= form_for @changeset, @action, [as: :user, autocomplete: "off"], fn f -> %> 27 | <%= if @changeset.action do %> 28 |
29 |

Oops, something went wrong! Please check the errors below.

30 |
31 | <% end %> 32 | 33 | <%= label f, :email, gettext("E-mail"), class: "label" %> 34 | <%= text_input f, Pow.Ecto.Schema.user_id_field(@changeset), class: "input" %> 35 | <%= error_tag f, Pow.Ecto.Schema.user_id_field(@changeset) %> 36 | 37 |
38 | <%= submit gettext("Submit"), class: "button" %> 39 |
40 | <% end %> 41 |
42 |
43 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/layout/app.html.leex: -------------------------------------------------------------------------------- 1 | <%= @inner_content %> 2 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/layout/navigation_admin.html.eex: -------------------------------------------------------------------------------- 1 | 32 | 33 | 36 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/layout/navigation_guest.html.eex: -------------------------------------------------------------------------------- 1 | 50 | 51 | 54 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/layout/navigation_user.html.eex: -------------------------------------------------------------------------------- 1 | <%= if @conn.assigns.current_subscription do 2 | live_render( 3 | @conn, 4 | DeerStorageWeb.SubscriptionNavigationLive, 5 | container: {:div, class: "navbar is-dark"}, 6 | session: %{ 7 | "header_text" => header_text(@conn), 8 | "subscription_id" => @conn.assigns.current_subscription.id, 9 | "subscription_tables" => compact_tables_to_ids_and_names(@conn.assigns.current_subscription.deer_tables), 10 | "storage_limit_kilobytes" => @conn.assigns.current_subscription.storage_limit_kilobytes, 11 | "locale" => @conn.assigns.locale 12 | } 13 | ) 14 | else 15 | live_render( 16 | @conn, 17 | DeerStorageWeb.SubscriptionNavigationLive, 18 | container: {:div, class: "navbar is-dark"}, 19 | session: %{ 20 | "missing_subscription" => true, 21 | "locale" => @conn.assigns.locale 22 | } 23 | ) 24 | end %> 25 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/layout/root.html.leex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <%= title(@conn) %> 9 | "/> 10 | 11 | 12 | 13 |
14 | <%= render_navigation(@conn) %> 15 | 16 | <%= if get_flash(@conn) != %{} do %> 17 | 18 | 19 | 20 | <% end %> 21 | 22 | <%= @inner_content %> 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/layout/without_navigation.html.leex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <%= title(@conn) %> 9 | "/> 10 | 11 | 12 | 13 |
14 | <%= if get_flash(@conn) != %{} do %> 15 | 16 | 17 | 18 | <% end %> 19 | 20 | <%= @inner_content %> 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/pagination/simple.html.eex: -------------------------------------------------------------------------------- 1 | <%= if @count == @per_page || @page != 1 do %> 2 | 15 | <% end %> 16 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/pow_email_confirmation/mailer/email_confirmation.html.eex: -------------------------------------------------------------------------------- 1 | <% new_url = DeerStorageWeb.Endpoint.url() <> @url %> 2 | 3 | <%= content_tag(:h3, "Hi,") %> 4 | <%= content_tag(:p, "Please use the following link to confirm your e-mail address:") %> 5 | <%= content_tag(:p, link(new_url, to: new_url)) %> 6 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/pow_email_confirmation/mailer/email_confirmation.text.eex: -------------------------------------------------------------------------------- 1 | Hi, 2 | 3 | Please use the following link to confirm your e-mail address: 4 | 5 | <%= DeerStorageWeb.Endpoint.url() <> @url %> 6 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/pow_invitation/mailer/invitation.html.eex: -------------------------------------------------------------------------------- 1 | <% new_url = DeerStorageWeb.Endpoint.url() <> @url %> 2 | 3 | <%= content_tag(:h3, "Hi,") %> 4 | <%= content_tag(:p, "You've been invited by #{@invited_by_user_id}. Please use the following link to accept your invitation:") %> 5 | <%= content_tag(:p, link(new_url, to: new_url)) %> 6 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/pow_invitation/mailer/invitation.text.eex: -------------------------------------------------------------------------------- 1 | Hi, 2 | 3 | You've been invited by <%= @invited_by_user_id %> to join their DeerStorage database. Please use the following link to accept your invitation: 4 | 5 | <%= DeerStorageWeb.Endpoint.url() <> @url %> 6 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/pow_reset_password/mailer/reset_password.html.eex: -------------------------------------------------------------------------------- 1 | <% new_url = DeerStorageWeb.Endpoint.url() <> @url %> 2 | 3 | <%= content_tag(:h3, "Hi,") %> 4 | <%= content_tag(:p, "Please use the following link to reset your password:") %> 5 | <%= content_tag(:p, link(new_url, to: new_url)) %> 6 | <%= content_tag(:p, "You can disregard this email if you didn't request a password reset.") %> 7 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/pow_reset_password/mailer/reset_password.text.eex: -------------------------------------------------------------------------------- 1 | Hi, 2 | 3 | Please use the following link to reset your password: 4 | 5 | <%= DeerStorageWeb.Endpoint.url() <> @url %> 6 | 7 | You can disregard this email if you didn't request a password reset. 8 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/registration/current_subscription.html.eex: -------------------------------------------------------------------------------- 1 | <%= if @changeset.data.role == "admin" do %> 2 | <%= link gettext("Switch to admin panel"), 3 | to: Routes.registration_path(@conn, :reset_subscription_id), 4 | method: :put, 5 | class: "button is-medium is-dark is-fullwidth" %> 6 |
7 | <% end %> 8 | 9 |
10 |
11 |
12 |
13 |

14 | <%= @current_subscription.name %>

15 | 16 | <%= link(gettext("Manage users or invite new"), to: Routes.user_path(@conn, :index)) %>
17 | <%= gettext("Connected users: %{users_count}", users_count: length(@current_subscription.users)) %>
18 |
19 | 20 | <% days = days_to_expire(@current_subscription) %> 21 | <%= if days < 1 do %> 22 |

23 | <%= gettext("This database expired") %> 24 |
25 | <% else %> 26 |
27 | <%= gettext("Total space limit: %{megabytes} MB", megabytes: ceil(@current_subscription.storage_limit_kilobytes / 1024)) %>
28 | <%= gettext("Total files limit: %{deer_files_limit}", deer_files_limit: @current_subscription.deer_files_limit) %>
29 | <%= gettext("Total tables limit: %{deer_tables_limit}", deer_tables_limit: @current_subscription.deer_tables_limit) %>
30 | <%= gettext("Total columns per table limit: %{deer_columns_per_table_limit}", deer_columns_per_table_limit: @current_subscription.deer_columns_per_table_limit) %>
31 | <%= gettext("Total records per table limit: %{deer_records_per_table_limit}", deer_records_per_table_limit: @current_subscription.deer_records_per_table_limit) %>
32 |
33 | <%= gettext("Expires in: %{days} days", days: days) %> 34 | <% end %> 35 |

36 |
37 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/registration/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Edit account") %> 6 |

7 |

8 | <%= gettext("Edit your DeerStorage account") %> 9 |

10 |
11 |
12 |
13 | 14 | <%= form_for @changeset, Routes.registration_path(@conn, :update), [as: :user, autocomplete: "off"], fn f -> %> 15 |
16 |
17 | <%= if @changeset.data.unconfirmed_email do %> 18 |
19 |

<%= gettext("Click the link in the confirmation email to change your email to:") %> <%= content_tag(:span, @changeset.data.unconfirmed_email) %>

20 |
21 | <% end %> 22 | 23 |
24 |
25 | <%= if @current_subscription do %> 26 | <%= render "current_subscription.html", 27 | conn: @conn, 28 | changeset: @changeset, 29 | current_subscription: @current_subscription %> 30 | <% else %> 31 | <%= if @changeset.data.role == "admin" do %> 32 | 35 |
36 | <% else %> 37 | <%= gettext("No database assigned") %> 38 | <% end %> 39 | <% end %> 40 | 41 |

42 | 43 | <%= if length(@available_subscriptions) != 0 do %> 44 |

<%= gettext("Available databases") %>

45 | 46 | <%= for available_subscription <- @available_subscriptions do %> 47 | 48 | 51 | 57 | 58 | <% end %> 59 |
49 | <%= available_subscription.name %> 50 | 52 | <%= link gettext("Switch"), 53 | to: Routes.registration_path(@conn, :switch_subscription_id, available_subscription.id), 54 | method: :put, 55 | class: "button is-primary is-small" %> 56 |
60 | <% end %> 61 |
62 | 63 | <%= render "user_fields.html", Map.put(assigns, :form, f) %> 64 |
65 |
66 |
67 | <% end %> 68 | 69 | 70 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/reset_password/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Reset password") %> 6 |

7 |

8 | <%= gettext("Reset your password") %> 9 |

10 |
11 |
12 |
13 | 14 |
15 |
16 | <%= form_for @changeset, @action, [as: :user], fn f -> %> 17 | <%= if @changeset.action do %> 18 |
19 |

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

20 |
21 | <% end %> 22 | 23 |
24 | <%= label f, :password, gettext("New Password"), class: "label" %> 25 | <%= password_input f, :password, class: "input" %> 26 | <%= error_tag f, :password %> 27 |
28 | 29 |
30 | <%= label f, :password_confirmation, class: "label" %> 31 | <%= password_input f, :password_confirmation, class: "input" %> 32 | <%= error_tag f, :password_confirmation %> 33 |
34 | 35 |
36 | <%= submit gettext("Submit"), class: "button" %> 37 |
38 | <% end %> 39 |
40 |
41 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/reset_password/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Reset password") %> 6 |

7 |

8 | <%= gettext("Send a link to reset your password") %> 9 |

10 |
11 |
12 |
13 | 14 |
15 |
16 | <%= form_for @changeset, @action, [as: :user], fn f -> %> 17 | <%= if @changeset.action do %> 18 |
19 |

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

20 |
21 | <% end %> 22 | 23 |
24 | <%= label f, :email, class: "label" %> 25 | <%= text_input f, :email, class: "input" %> 26 | <%= error_tag f, :email %> 27 |
28 | 29 |
30 | <%= submit gettext("Submit"), class: "button" %> 31 |
32 | <% end %> 33 |
34 |
35 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/session/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Sign in") %> 6 |

7 |

8 | <%= gettext("Sign in to DeerStorage") %> 9 |

10 |
11 |
12 |
13 | 14 |
15 |
16 | <%= form_for @changeset, Routes.session_path(@conn, :create), [as: :user], fn f -> %> 17 | <%= if @changeset.action do %> 18 |
19 |

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

20 |
21 | <% end %> 22 | 23 |
24 | <%= label f, :email, gettext("E-mail"), class: "label" %> 25 | <%= text_input f, Pow.Ecto.Schema.user_id_field(@changeset), class: "input" %> 26 | <%= error_tag f, Pow.Ecto.Schema.user_id_field(@changeset) %> 27 |
28 | 29 |
30 | <%= label f, :password, gettext("Password"), class: "label" %> 31 | <%= password_input f, :password, class: "input" %> 32 | <%= error_tag f, :password %> 33 |
34 | 35 |
36 | <%= checkbox f, :persistent_session, class: "checkbox", value: true %>  37 | <%= label f, :persistent_session, gettext("Remember me (for 30 days)"), class: "checkbox" %> 38 |
39 | 40 |
41 | <%= submit gettext("Sign in"), class: "button" %> 42 |
43 | <% end %> 44 |
45 | 46 | <%= if registration_enabled?() do %> 47 | <%= link(gettext("Register"), to: Routes.registration_path(@conn, :new)) %> 48 | <% end %> 49 | 50 | <%= if mailing_enabled?() do %> 51 | / <%= link(gettext("Reset password"), to: Routes.reset_password_path(@conn, :new)) %> 52 | <% end %> 53 |
54 |
55 | -------------------------------------------------------------------------------- /lib/deer_storage_web/templates/user/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Manage users") %> 6 |

7 |

8 | <%= gettext("Invite or remove connected DeerStorage users") %> 9 |

10 |
11 |
12 |
13 | 14 | 15 |
16 |
17 | <%= if @current_user_can_manage_users do %> 18 | <%= link(gettext("Invite new user"), to: Routes.invitation_path(@conn, :new)) %> 19 | <% end %> 20 |
21 | 22 |
23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | <%= for {user, user_subscription_link} <- @users do %> 36 | 37 | 44 | 45 | 46 | 55 | 56 | <% end %> 57 | 58 |
<%= gettext("Name and surname") %><%= gettext("E-mail") %><%= gettext("Can manage users?") %><%= gettext("Other") %>
38 | <%= if user.name do %> 39 | <%= user.name %> 40 | <% else %> 41 | <%= gettext("(empty)") %> 42 | <% end %> 43 | <%= user.email %><%= link_to_toggle_permission(user, user_subscription_link, :permission_to_manage_users, @current_user_can_manage_users, @current_user) %> 47 | <%= if @current_user_can_manage_users && user.id != @current_user.id do %> 48 | <%= button gettext("Unlink"), 49 | to: Routes.user_user_path(@conn, :unlink, user.id, @current_subscription.id), 50 | method: :put, 51 | data: [confirm: gettext("Are you sure to unlink %{name} from your database?", name: user.name)], 52 | class: "button is-danger" %> 53 | <% end %> 54 |
59 | 60 | <%= link gettext("Disconnect from this database"), 61 | to: Routes.user_user_path(@conn, :unlink, @current_user.id, @current_subscription.id), 62 | method: :put, 63 | data: [confirm: gettext("Are you sure to DISCONNECT from database %{name}?", name: @current_subscription.name)] %> 64 | (<%= gettext("Requires invitation to join it again") %>) 65 |
66 |
67 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/admin/dashboard_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.Admin.DashboardView do 2 | use DeerStorageWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/deer_dashboard_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.DeerDashboardView do 2 | use DeerStorageWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), class: "help is-danger") 14 | end) 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # When using gettext, we typically pass the strings we want 22 | # to translate as a static argument: 23 | # 24 | # # Translate "is invalid" in the "errors" domain 25 | # dgettext("errors", "is invalid") 26 | # 27 | # # Translate the number of files with plural rules 28 | # dngettext("errors", "1 file", "%{count} files", count) 29 | # 30 | # Because the error messages we show in our forms and APIs 31 | # are defined inside Ecto, we need to translate them dynamically. 32 | # This requires us to call the Gettext module passing our gettext 33 | # backend as first argument. 34 | # 35 | # Note we use the "errors" domain, which means translations 36 | # should be written to the errors.po file. The :count option is 37 | # set by Ecto and indicates we should also apply plural rules. 38 | if count = opts[:count] do 39 | Gettext.dngettext(DeerStorageWeb.Gettext, "errors", msg, msg, count, opts) 40 | else 41 | Gettext.dgettext(DeerStorageWeb.Gettext, "errors", msg, opts) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ErrorView do 2 | use DeerStorageWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/invitation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.InvitationView do 2 | import DeerStorage.FeatureFlags, only: [mailing_disabled?: 0] 3 | use DeerStorageWeb, :view 4 | end 5 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.LayoutView do 2 | import DeerStorage.FeatureFlags, only: [registration_enabled?: 0] 3 | use DeerStorageWeb, :view 4 | 5 | def available_languages_and_locales(), do: [{"Polski", "pl"}, {"English", "en"}] 6 | 7 | def maybe_active_dashboard_link(socket, header_str) do 8 | header_str = if header_str, do: "🏠 " <> header_str 9 | class = case socket.view do 10 | DeerStorageWeb.DeerDashboardLive.Index -> "navbar-item has-text-weight-bold is-active" 11 | _ -> "navbar-item has-text-weight-bold" 12 | end 13 | 14 | 15 | live_redirect(header_str, to: Routes.live_path(socket, DeerStorageWeb.DeerDashboardLive.Index), class: class) 16 | end 17 | 18 | def maybe_active_records_link(socket, %{id: id} = dt, id) do 19 | live_redirect "#{dt.name} (#{dt.count})", to: Routes.live_path(socket, DeerStorageWeb.DeerRecordsLive.Index, dt.id), class: "navbar-item is-active" 20 | end 21 | 22 | def maybe_active_records_link(socket, dt, _) do 23 | live_redirect "#{dt.name} (#{dt.count})", to: Routes.live_path(socket, DeerStorageWeb.DeerRecordsLive.Index, dt.id), class: "navbar-item" 24 | end 25 | 26 | def title(conn) do 27 | case conn.assigns[:title] do 28 | nil -> gettext("DeerStorage") 29 | title -> title 30 | end 31 | end 32 | 33 | def compact_tables_to_ids_and_names(deer_tables) do 34 | Enum.map(deer_tables, fn %{id: id, name: name} -> %{id: id, name: name} end) 35 | end 36 | 37 | def header_text(%{assigns: %{current_subscription_is_expired: true}}), do: gettext("DATABASE EXPIRED") 38 | def header_text(%{assigns: %{current_subscription: %{name: name}}}), do: name 39 | def header_text(%{assigns: %{current_user: %{name: name}}}), do: name 40 | def header_text(_), do: gettext("DeerStorage") 41 | 42 | def render_navigation(%{assigns: %{current_user: nil}} = conn), do: render "navigation_guest.html", conn: conn 43 | def render_navigation(%{assigns: %{current_user: %{role: "admin"}, current_subscription: nil}} = conn) do 44 | render "navigation_admin.html", conn: conn 45 | end 46 | def render_navigation(conn), do: render "navigation_user.html", conn: conn 47 | end 48 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.PageView do 2 | import DeerStorage.FeatureFlags, only: [registration_enabled?: 0] 3 | use DeerStorageWeb, :view 4 | 5 | def dashboard_link(%{assigns: %{current_user: %{role: "admin"}, current_subscription: nil}}) do 6 | link gettext("Go back to Admin Panel..."), to: Routes.admin_live_path(DeerStorageWeb.Endpoint, DeerStorageWeb.Admin.DashboardLive.Index), class: "button is-link" 7 | end 8 | 9 | def dashboard_link(_conn) do 10 | link gettext("Go back to your database..."), to: Routes.live_path(DeerStorageWeb.Endpoint, DeerStorageWeb.DeerDashboardLive.Index), class: "button is-link" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/pagination_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.PaginationView do 2 | use DeerStorageWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/pow_email_confirmation/mailer_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.PowEmailConfirmation.MailerView do 2 | use DeerStorageWeb, :mailer_view 3 | 4 | def subject(:email_confirmation, _assigns), do: "Confirm your email address" 5 | end 6 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/pow_invitation/mailer_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.PowInvitation.MailerView do 2 | use DeerStorageWeb, :mailer_view 3 | 4 | def subject(:invitation, _assigns), do: "You've been invited" 5 | end 6 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/pow_reset_password/mailer_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.PowResetPassword.MailerView do 2 | use DeerStorageWeb, :mailer_view 3 | 4 | def subject(:reset_password, _assigns), do: "Reset password link" 5 | end 6 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/registration_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.RegistrationView do 2 | import DeerStorage.FeatureFlags, only: [mailing_enabled?: 0] 3 | use DeerStorageWeb, :view 4 | 5 | def time_zones_select_options, do: Tzdata.zone_list 6 | 7 | def languages_select_options do 8 | [ 9 | [gettext("Polish"), "pl"], 10 | [gettext("English"), "en"], 11 | ] |> Map.new(fn [k, v] -> {k, v} end) 12 | end 13 | 14 | def days_to_expire(nil), do: "???" 15 | def days_to_expire(subscription), do: Date.diff subscription.expires_on, Date.utc_today 16 | end 17 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/reset_password_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ResetPasswordView do 2 | use DeerStorageWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.SessionView do 2 | import DeerStorage.FeatureFlags, only: [registration_enabled?: 0, mailing_enabled?: 0] 3 | use DeerStorageWeb, :view 4 | end 5 | -------------------------------------------------------------------------------- /lib/deer_storage_web/views/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.UserView do 2 | use DeerStorageWeb, :view 3 | 4 | # admins can do whatever they want 5 | def link_to_toggle_permission(%{role: "admin"}, _user_permissions_link, _permission_key, _can_manage?, _current_user), do: "admin" 6 | 7 | # admins can click whatever they want 8 | def link_to_toggle_permission(user, user_permissions_link, permission_key, _can_manage?, %{role: "admin"}) do 9 | clickable_button(user_permissions_link, permission_key, user.id, true) 10 | end 11 | 12 | # current user can't change their permissions 13 | def link_to_toggle_permission(%{id: current_user_id}, user_permissions_link, permission_key, _can_manage?, %{id: current_user_id}) do 14 | clickable_button(user_permissions_link, permission_key, current_user_id, false) 15 | end 16 | 17 | # if current user can't manage other users 18 | def link_to_toggle_permission(%{id: user_id}, user_permissions_link, permission_key, false, _current_user) do 19 | clickable_button(user_permissions_link, permission_key, user_id, false) 20 | end 21 | 22 | # if current user can manage users 23 | def link_to_toggle_permission(user, user_permissions_link, permission_key, true, _current_user) do 24 | clickable_button(user_permissions_link, permission_key, user.id, true) 25 | end 26 | 27 | defp clickable_button(%{subscription_id: subscription_id} = user_permissions_link, permission_key, user_id, is_enabled) do 28 | is_permitted? = user_permissions_link[permission_key] 29 | classes = if is_permitted?, do: "button is-light is-success", else: "button is-light is-danger" 30 | 31 | button(is_permitted? |> translated_yes_or_no, 32 | to: Routes.user_user_path(DeerStorageWeb.Endpoint, :toggle_permission, user_id, subscription_id, permission_key), 33 | method: :put, 34 | data: [confirm: gettext("Are you sure to change this permission?")], 35 | class: classes, 36 | disabled: !is_enabled 37 | ) 38 | end 39 | 40 | defp translated_yes_or_no(true), do: gettext("Yes") 41 | defp translated_yes_or_no(false), do: gettext("No") 42 | end 43 | -------------------------------------------------------------------------------- /lib/tasks/deer_storage.add_fakered_cars_to_subscription.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.DeerStorage.AddFakeredCarsToSubscription do 2 | use Mix.Task 3 | import DeerStorage.Subscriptions 4 | import DeerStorage.DeerRecords 5 | 6 | @shortdoc "Upserts Fake Cars table to Subscription and creates random records" 7 | 8 | def run([subscription_id, count]) do 9 | Mix.Task.run "app.start" 10 | 11 | find_fake_cars = fn deer_tables -> Enum.find(deer_tables, fn dt -> dt.name == "Fake cars" end) end 12 | fill_fake_content = fn name -> 13 | case name do 14 | "Make and model" -> Faker.Vehicle.make_and_model() 15 | "Year" -> Enum.random(1970..2020) |> Integer.to_string 16 | "Transmission" -> Faker.Vehicle.transmission() 17 | "Fuel type" -> Faker.Vehicle.fuel_type() 18 | "VIN" -> Faker.Vehicle.vin() 19 | "Drivetrain" -> Faker.Vehicle.drivetrain() 20 | "Specification" -> Faker.Vehicle.standard_specs() |> Enum.join(", ") 21 | end 22 | end 23 | 24 | count = String.to_integer(count) 25 | subscription = String.to_integer(subscription_id) |> get_subscription! 26 | 27 | {subscription, deer_table} = case find_fake_cars.(subscription.deer_tables) do 28 | nil -> 29 | {:ok, subscription} = create_deer_table!(subscription, "Fake cars", 30 | ["Make and model", "Year", "Transmission", "Fuel type", "VIN", "Drivetrain", "Specification"]) 31 | 32 | {subscription, find_fake_cars.(subscription.deer_tables)} 33 | fake_cars -> {subscription, fake_cars} 34 | end 35 | 36 | Enum.each(1..count, fn n -> 37 | deer_fields_arr = Enum.reduce(deer_table.deer_columns, [], fn(%{id: deer_column_id, name: name}, arr) -> 38 | [%{deer_column_id: deer_column_id, content: fill_fake_content.(name)} | arr] 39 | end) 40 | 41 | {:ok, _record} = create_record( 42 | subscription, %{ 43 | deer_table_id: deer_table.id, 44 | deer_fields: deer_fields_arr 45 | } 46 | ) 47 | 48 | Mix.shell.info Integer.to_string(n) 49 | end) 50 | 51 | Mix.shell.info "Done." 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/tasks/deer_storage.add_fakered_people_to_subscription.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.DeerStorage.AddFakeredPeopleToSubscription do 2 | use Mix.Task 3 | import DeerStorage.Subscriptions 4 | import DeerStorage.DeerRecords 5 | 6 | @shortdoc "Upserts Fake People table to Subscription and creates random records" 7 | 8 | def run([subscription_id, count]) do 9 | Mix.Task.run "app.start" 10 | 11 | find_fake_people = fn deer_tables -> Enum.find(deer_tables, fn dt -> dt.name == "Fake People" end) end 12 | fill_fake_content = fn name -> 13 | case name do 14 | "Name" -> Faker.Person.name() 15 | "Age" -> Enum.random(18..70) |> Integer.to_string 16 | "City" -> Faker.Address.city() 17 | "Country" -> Faker.Address.country() 18 | "Mobile phone" -> Faker.Phone.EnUs.phone() 19 | end 20 | end 21 | 22 | count = String.to_integer(count) 23 | subscription = String.to_integer(subscription_id) |> get_subscription! 24 | 25 | {subscription, deer_table} = case find_fake_people.(subscription.deer_tables) do 26 | nil -> 27 | {:ok, subscription} = create_deer_table!(subscription, "Fake People", 28 | ["Name", "Age", "City", "Country", "Mobile phone"]) 29 | 30 | {subscription, find_fake_people.(subscription.deer_tables)} 31 | fake_people -> {subscription, fake_people} 32 | end 33 | 34 | Enum.each(1..count, fn n -> 35 | deer_fields_arr = Enum.reduce(deer_table.deer_columns, [], fn(%{id: deer_column_id, name: name}, arr) -> 36 | [%{deer_column_id: deer_column_id, content: fill_fake_content.(name)} | arr] 37 | end) 38 | 39 | {:ok, _record} = create_record( 40 | subscription, %{ 41 | deer_table_id: deer_table.id, 42 | deer_fields: deer_fields_arr 43 | } 44 | ) 45 | 46 | Mix.shell.info Integer.to_string(n) 47 | end) 48 | 49 | Mix.shell.info "Done." 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/tasks/deer_storage.create_user.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.DeerStorage.CreateUser do 2 | use Mix.Task 3 | 4 | alias DeerStorage.Users 5 | 6 | @shortdoc "Creates user, eg. mix deer_storage.create_user admin user@example.org" 7 | 8 | def run([role, email]) do 9 | Mix.Task.run "app.start" 10 | Mix.shell.info "Generating random password..." 11 | password = :crypto.strong_rand_bytes(64) |> Base.encode64 12 | Mix.shell.info "Password for user #{email} will be: #{password}" 13 | Mix.shell.info "Creating user..." 14 | {:ok, _} = Users.admin_create_user(%{email: email, name: "Change me", password: password, password_confirmation: password, role: role}) 15 | Mix.shell.info "Created user #{email}..." 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/.well-known/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/priv/.well-known/.keep -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/people.po: -------------------------------------------------------------------------------- 1 | ## "msgid"s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove "msgid"s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use "mix gettext.extract --merge" or "mix gettext.merge" 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | "Plural-Forms: nplurals=2\n" 13 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | ## From Ecto.Changeset.cast/4 11 | msgid "can't be blank" 12 | msgstr "" 13 | 14 | ## From Ecto.Changeset.unique_constraint/3 15 | msgid "has already been taken" 16 | msgstr "" 17 | 18 | ## From Ecto.Changeset.put_change/3 19 | msgid "is invalid" 20 | msgstr "" 21 | 22 | ## From Ecto.Changeset.validate_acceptance/3 23 | msgid "must be accepted" 24 | msgstr "" 25 | 26 | ## From Ecto.Changeset.validate_format/3 27 | msgid "has invalid format" 28 | msgstr "" 29 | 30 | ## From Ecto.Changeset.validate_subset/3 31 | msgid "has an invalid entry" 32 | msgstr "" 33 | 34 | ## From Ecto.Changeset.validate_exclusion/3 35 | msgid "is reserved" 36 | msgstr "" 37 | 38 | ## From Ecto.Changeset.validate_confirmation/3 39 | msgid "does not match confirmation" 40 | msgstr "" 41 | 42 | ## From Ecto.Changeset.no_assoc_constraint/3 43 | msgid "is still associated with this entry" 44 | msgstr "" 45 | 46 | msgid "are still associated with this entry" 47 | msgstr "" 48 | 49 | ## From Ecto.Changeset.validate_length/3 50 | msgid "should be %{count} character(s)" 51 | msgid_plural "should be %{count} character(s)" 52 | msgstr[0] "" 53 | msgstr[1] "" 54 | 55 | msgid "should have %{count} item(s)" 56 | msgid_plural "should have %{count} item(s)" 57 | msgstr[0] "" 58 | msgstr[1] "" 59 | 60 | msgid "should be at least %{count} character(s)" 61 | msgid_plural "should be at least %{count} character(s)" 62 | msgstr[0] "" 63 | msgstr[1] "" 64 | 65 | msgid "should have at least %{count} item(s)" 66 | msgid_plural "should have at least %{count} item(s)" 67 | msgstr[0] "" 68 | msgstr[1] "" 69 | 70 | msgid "should be at most %{count} character(s)" 71 | msgid_plural "should be at most %{count} character(s)" 72 | msgstr[0] "" 73 | msgstr[1] "" 74 | 75 | msgid "should have at most %{count} item(s)" 76 | msgid_plural "should have at most %{count} item(s)" 77 | msgstr[0] "" 78 | msgstr[1] "" 79 | 80 | ## From Ecto.Changeset.validate_number/3 81 | msgid "must be less than %{number}" 82 | msgstr "" 83 | 84 | msgid "must be greater than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be less than or equal to %{number}" 88 | msgstr "" 89 | 90 | msgid "must be greater than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be equal to %{number}" 94 | msgstr "" 95 | -------------------------------------------------------------------------------- /priv/gettext/people.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 | -------------------------------------------------------------------------------- /priv/gettext/pl/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## "msgid"s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove "msgid"s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use "mix gettext.extract --merge" or "mix gettext.merge" 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: pl\n" 12 | "Plural-Forms: nplurals=3\n" 13 | 14 | msgid "can't be blank" 15 | msgstr "nie może być puste" 16 | 17 | msgid "has already been taken" 18 | msgstr "jest zajęte" 19 | 20 | msgid "is invalid" 21 | msgstr "jest niepoprawne" 22 | 23 | msgid "must be accepted" 24 | msgstr "musi zostać zaakceptowane" 25 | 26 | msgid "has invalid format" 27 | msgstr "ma niepoprawny format" 28 | 29 | msgid "has an invalid entry" 30 | msgstr "ma niepoprawny wpis" 31 | 32 | msgid "is reserved" 33 | msgstr "jest zarezerwowane" 34 | 35 | msgid "does not match confirmation" 36 | msgstr "" 37 | 38 | msgid "is still associated with this entry" 39 | msgstr "" 40 | 41 | msgid "are still associated with this entry" 42 | msgstr "" 43 | 44 | msgid "should be %{count} character(s)" 45 | msgid_plural "should be %{count} character(s)" 46 | msgstr[0] "" 47 | msgstr[1] "" 48 | msgstr[2] "" 49 | 50 | msgid "should have %{count} item(s)" 51 | msgid_plural "should have %{count} item(s)" 52 | msgstr[0] "" 53 | msgstr[1] "" 54 | msgstr[2] "" 55 | 56 | msgid "should be at least %{count} character(s)" 57 | msgid_plural "should be at least %{count} character(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | msgstr[2] "" 61 | 62 | msgid "should have at least %{count} item(s)" 63 | msgid_plural "should have at least %{count} item(s)" 64 | msgstr[0] "" 65 | msgstr[1] "" 66 | msgstr[2] "" 67 | 68 | msgid "should be at most %{count} character(s)" 69 | msgid_plural "should be at most %{count} character(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | msgstr[2] "" 73 | 74 | msgid "should have at most %{count} item(s)" 75 | msgid_plural "should have at most %{count} item(s)" 76 | msgstr[0] "" 77 | msgstr[1] "" 78 | msgstr[2] "" 79 | 80 | msgid "must be less than %{number}" 81 | msgstr "" 82 | 83 | msgid "must be greater than %{number}" 84 | msgstr "" 85 | 86 | msgid "must be less than or equal to %{number}" 87 | msgstr "" 88 | 89 | msgid "must be greater than or equal to %{number}" 90 | msgstr "" 91 | 92 | msgid "must be equal to %{number}" 93 | msgstr "" 94 | -------------------------------------------------------------------------------- /priv/gettext/pl/LC_MESSAGES/people.po: -------------------------------------------------------------------------------- 1 | ## "msgid"s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove "msgid"s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use "mix gettext.extract --merge" or "mix gettext.merge" 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: pl\n" 12 | "Plural-Forms: nplurals=3\n" 13 | -------------------------------------------------------------------------------- /priv/mnesia/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intpl/deer_storage/6e9d1155ea72362d627a06284cb6cddb85e2ade1/priv/mnesia/.keep -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191101143314_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :email, :string, null: false 7 | add :password_hash, :string 8 | 9 | timestamps() 10 | end 11 | 12 | create unique_index(:users, [:email]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191102143340_alter_users_with_locale.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AlterUsersWithLocales do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :locale, :string, default: "en", null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191102181046_alter_users_with_displayed_name.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AlterUsersWithDisplayedName do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :displayed_name, :string 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191104095235_alter_users_with_role.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AlterUsersWithRole do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :role, :string, default: "user" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191105215425_alter_users_with_admin_notes.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AlterUsersWithAdminNotes do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :admin_notes, :text 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191108160955_create_subscriptions.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.CreateSubscriptions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:subscriptions) do 6 | add :name, :string, null: false 7 | add :expires_on, :date, null: false 8 | add :data, :jsonb 9 | 10 | timestamps() 11 | end 12 | 13 | alter table(:users) do 14 | add :subscription_id, references(:subscriptions, on_delete: :nilify_all) 15 | end 16 | 17 | create index(:users, [:subscription_id]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191118151954_rename_displayed_name_to_name_in_users.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.RenameDisplayedNameToNameInUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename table(:users), :displayed_name, to: :name 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191119140722_alter_subscriptions_with_admin_notes.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AlterSubscriptionsWithAdminNotes do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:subscriptions) do 6 | add :admin_notes, :text 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191120095545_alter_subscriptions_with_time_zone.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AlterUsersWithTimeZone do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :time_zone, :string, default: "Europe/Warsaw" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200307083533_create_extension_unaccent.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.CreateExtensionUnaccent do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute "CREATE EXTENSION unaccent", "DROP EXTENSION unaccent" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200312044234_add_pow_email_confirmation_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddPowEmailConfirmationToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :email_confirmation_token, :string 7 | add :email_confirmed_at, :utc_datetime 8 | add :unconfirmed_email, :string 9 | end 10 | 11 | create unique_index(:users, [:email_confirmation_token]) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200315054554_add_pow_invitation_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddPowInvitationToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :invitation_token, :string 7 | add :invitation_accepted_at, :utc_datetime 8 | add :invited_by_id, references("users", on_delete: :nothing) 9 | end 10 | 11 | create unique_index(:users, [:invitation_token]) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200320084407_create_user_available_subscription_links.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.CreateUserAvailableSubscriptionLinks do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:user_available_subscription_links) do 6 | add(:user_id, references(:users, on_delete: :delete_all), primary_key: true) 7 | add(:subscription_id, references(:subscriptions, on_delete: :delete_all), primary_key: true) 8 | timestamps() 9 | end 10 | 11 | create(index(:user_available_subscription_links, [:subscription_id])) 12 | create(index(:user_available_subscription_links, [:user_id])) 13 | 14 | # TODO determine if this is needed in the future 15 | create(index(:user_available_subscription_links, [:user_id, :subscription_id])) 16 | 17 | create( 18 | unique_index(:user_available_subscription_links, [:user_id, :subscription_id], name: :user_id_available_subscription_id_unique_index) 19 | ) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200502074234_rename_users_subscription_id_to_last_used_subscription_id.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.RenameUsersSubscriptionIdToLastUsedSubscriptionId do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename table("users"), :subscription_id, to: :last_used_subscription_id 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200506181625_add_deer_tables_to_subscriptions.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddDeerTablesToSubscriptions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:subscriptions) do 6 | add :deer_tables, {:array, :map}, default: [] 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200507065258_create_deer_records.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.CreateDeerRecords do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:deer_records) do 6 | add :subscription_id, references(:subscriptions, on_delete: :delete_all), null: false 7 | add :created_by_user_id, references(:users, on_delete: :nilify_all) 8 | add :updated_by_user_id, references(:users, on_delete: :nilify_all) 9 | add :deer_table_id, :string, null: false 10 | add :deer_fields, {:array, :map}, default: [] 11 | 12 | timestamps() 13 | end 14 | 15 | create index(:deer_records, [:subscription_id]) 16 | create index(:deer_records, [:subscription_id, :deer_table_id]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200829081750_add_deer_files_to_deer_records.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddDeerFilesToDeerRecords do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:deer_records) do 6 | add :deer_files, {:array, :map}, default: [] 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200902052439_add_storage_limit_to_subscriptions.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddStorageLimitToSubscriptions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:subscriptions) do 6 | add :storage_limit_kilobytes, :integer, default: 0 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200904173814_add_permission_to_manage_users_to_user_available_subscription_links.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddPermissionToManageUsersToUserAvailableSubscriptionLinks do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:user_available_subscription_links) do 6 | add :permission_to_manage_users, :boolean, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200906082412_add_deer_file_limit_to_subscription.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddDeerFileLimitToSubscription do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:subscriptions) do 6 | add :deer_files_limit, :integer, default: 0 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200906130111_add_deer_records_per_table_limit_to_subscription.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddDeerRecordsPerTableLimitToSubscription do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:subscriptions) do 6 | add :deer_records_per_table_limit, :integer, default: 0 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200906130131_add_deer_tables_limit_to_subscription.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddDeerTablesLimitToSubscription do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:subscriptions) do 6 | add :deer_tables_limit, :integer, default: 0 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200909034056_add_deer_columns_per_table_limit_to_subscription.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddDeerColumnsPerTableLimitToSubscription do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:subscriptions) do 6 | add :deer_columns_per_table_limit, :integer, default: 0 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201006132706_enable_pg_crypto.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.EnablePgCrypto do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute( 6 | "CREATE EXTENSION IF NOT EXISTS \"pgcrypto\"", 7 | "DROP EXTENSION IF EXISTS \"pgcrypto\"" 8 | ) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201006141242_create_shared_records.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.CreateSharedRecords do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:shared_records, primary_key: false) do 6 | add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()") 7 | add :deer_record_id, references("deer_records", on_delete: :delete_all) 8 | add :expires_on, :utc_datetime, null: false 9 | add :created_by_user_id, references("users", on_delete: :nothing) 10 | add :subscription_id, references("subscriptions", on_delete: :delete_all) 11 | 12 | timestamps(updated_at: false) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201016074452_add_connected_deer_records_ids_to_deer_records.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddConnectedDeerRecordsIdsToDeerRecords do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:deer_records) do 6 | add :connected_deer_records_ids, {:array, :bigint}, default: [] 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201115072646_add_editing_bool_to_deer_shared_records.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddEditingBoolToDeerSharedRecords do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:shared_records) do 6 | add :is_editable, :boolean, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201230064915_create_shared_files.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.CreateSharedFiles do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:shared_files, primary_key: false) do 6 | add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()") 7 | add :deer_record_id, references("deer_records", on_delete: :delete_all) 8 | add :expires_on, :utc_datetime, null: false 9 | add :created_by_user_id, references("users", on_delete: :nothing) 10 | add :subscription_id, references("subscriptions", on_delete: :delete_all) 11 | add :deer_file_id, :string 12 | 13 | timestamps(updated_at: false) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210126174050_add_notes_to_deer_records.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.AddNotesToDeerRecords do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:deer_records) do 6 | add :notes, :text 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210222120410_generate_files_mimetypes_and_update_records.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Repo.Migrations.GenerateFilesMimetypesAndUpdateRecords do 2 | use Ecto.Migration 3 | import Ecto.Query, warn: false 4 | import Ecto.Changeset 5 | 6 | def change do 7 | records_with_files = DeerStorage.DeerRecords.DeerRecord |> where([r], fragment("cardinality(?) > 0", field(r, :deer_files))) |> DeerStorage.Repo.all 8 | 9 | for %{deer_files: deer_files, deer_table_id: deer_table_id, subscription_id: subscription_id, id: record_id} = record <- records_with_files do 10 | new_deer_files = Enum.map(deer_files, fn %{id: df_id} = df -> 11 | file_path = File.cwd! <> "/uploaded_files/#{subscription_id}/#{deer_table_id}/#{record_id}/#{df_id}" 12 | {mimetype_with_newline, 0 = _exit_status} = System.cmd("file", ["--mime-type", "-b", file_path]) 13 | 14 | Map.from_struct(df) |> Map.put(:mimetype, String.trim(mimetype_with_newline)) 15 | end) 16 | 17 | IO.puts "Updating record with id #{record_id}..." 18 | 19 | record 20 | |> change 21 | |> cast(%{deer_files: new_deer_files}, []) 22 | |> cast_embed(:deer_files) 23 | |> DeerStorage.Repo.update! 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/csv_examples/polish_people.csv: -------------------------------------------------------------------------------- 1 | Imię,Nazwisko,Data urodzenia 2 | Marian,Kowalski,2.11.1990 3 | Jadwiga,Muchomor,1.12.1970 4 | Zenon,Pasztet,1.01.1964 5 | -------------------------------------------------------------------------------- /test/deer_cache/records_counts_cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerCacheRecordsCountsCacheTest do 2 | use DeerStorage.DataCase 3 | import DeerStorage.DeerFixtures 4 | 5 | describe "fetch_count/1" do 6 | test "responds with 0 when invalid id" do 7 | assert DeerCache.RecordsCountsCache.fetch_count("invalid") == 0 8 | end 9 | 10 | test "responds with count when deer records exist" do 11 | subscription = create_valid_subscription_with_tables(2) 12 | create_valid_records_for_subscription(subscription, 5) 13 | 14 | deer_tables_ids = subscription.deer_tables |> Enum.map(fn dt -> dt.id end) 15 | 16 | Enum.each(deer_tables_ids, fn id -> 17 | assert DeerCache.RecordsCountsCache.fetch_count(id) == 5 18 | end) 19 | end 20 | 21 | test "tracks counts after record create/delete" do 22 | subscription = create_valid_subscription_with_tables() 23 | [%{deer_table_id: table_id} = first_record | _] = create_valid_records_for_subscription(subscription, 5) 24 | 25 | assert DeerCache.RecordsCountsCache.fetch_count(table_id) == 5 26 | 27 | create_valid_records_for_subscription(subscription, 5) 28 | assert DeerCache.RecordsCountsCache.fetch_count(table_id) == 10 29 | 30 | DeerStorage.DeerRecords.delete_record(subscription, first_record) 31 | assert DeerCache.RecordsCountsCache.fetch_count(table_id) == 9 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/deer_storage/db_helpers/deer_records_search_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.DeerRecordsSearchTest do 2 | use DeerStorage.DataCase 3 | import DeerStorage.DeerFixtures 4 | 5 | alias DeerStorage.DbHelpers.DeerRecordsSearch 6 | 7 | describe "search_records/4" do 8 | test "composing" do 9 | subscription = create_valid_subscription_with_tables(2, 2) 10 | deer_table = subscription.deer_tables |> List.first 11 | create_valid_records_for_subscription(subscription, 3) # therefore total records count is 6 12 | 13 | result = DeerRecordsSearch.search_records(subscription.id, deer_table.id, ["Content"], 1) 14 | assert length(result) == 3 15 | 16 | result2 = DeerRecordsSearch.search_records(subscription.id, deer_table.id, ["Content", "1"], 1) 17 | assert length(result2) == 1 18 | 19 | result3 = DeerRecordsSearch.search_records(subscription.id, deer_table.id, ["non-existing-content"], 1) 20 | assert Enum.empty?(result3) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/deer_storage/shared_records/shared_records_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.SharedRecordsTest do 2 | use DeerStorage.DataCase 3 | import DeerStorage.Fixtures 4 | import DeerStorage.DeerFixtures 5 | import DeerStorage.Users, only: [upsert_subscription_link!: 3] 6 | 7 | alias DeerStorage.SharedRecords 8 | 9 | describe "create_record!/3 and get_record!/2" do 10 | setup do 11 | subscription = create_valid_subscription_with_tables(1, 2) 12 | user = create_user_without_subscription() 13 | 14 | upsert_subscription_link!(user.id, subscription.id, :raise) 15 | 16 | [record] = create_valid_records_for_subscription(subscription) 17 | 18 | {:ok, user: user, subscription: subscription, record: record} 19 | end 20 | 21 | test "valid attrs", %{subscription: subscription, user: user, record: record} do 22 | created_record = SharedRecords.create_record!(subscription.id, user.id, record.id) 23 | 24 | loaded_record = SharedRecords.get_record!(subscription.id, created_record.id) 25 | 26 | assert loaded_record.deer_record_id == record.id 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/deer_storage/subscription_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.SubscriptionTest do 2 | use DeerStorage.DataCase 3 | 4 | describe "subscriptions" do 5 | alias DeerStorage.Subscriptions.Subscription 6 | alias DeerStorage.Subscriptions 7 | 8 | @valid_attrs %{name: "Example"} 9 | @update_attrs %{name: "Example2"} 10 | @invalid_attrs %{name: "", expires_on: ~N[2011-05-18 15:01:01]} 11 | 12 | def subscription_fixture(attrs \\ %{}) do 13 | {:ok, subscription} = 14 | attrs 15 | |> Enum.into(@valid_attrs) 16 | |> Subscriptions.create_subscription() 17 | 18 | subscription |> Repo.preload(:users) 19 | end 20 | 21 | test "list_subscriptions/0 returns all subscriptions" do 22 | subscription = subscription_fixture() 23 | assert Subscriptions.list_subscriptions() == [subscription] 24 | end 25 | 26 | test "get_subscription!/1 returns the subscription with given id" do 27 | subscription = subscription_fixture() 28 | assert Subscriptions.get_subscription!(subscription.id) == subscription 29 | end 30 | 31 | test "create_subscription/1 with valid data creates a subscription" do 32 | assert {:ok, %Subscription{} = subscription} = Subscriptions.create_subscription(@valid_attrs) 33 | assert subscription.name == "Example" 34 | end 35 | 36 | test "create_subscription/1 with invalid data returns error changeset" do 37 | assert {:error, %Ecto.Changeset{}} = Subscriptions.create_subscription(@invalid_attrs) 38 | end 39 | 40 | test "update_subscription/2 with valid data updates the subscription" do 41 | subscription = subscription_fixture() 42 | assert {:ok, %Subscription{} = subscription} = Subscriptions.update_subscription(subscription, @update_attrs) 43 | assert subscription.name == "Example2" 44 | end 45 | 46 | test "update_subscription/2 with invalid data returns error changeset" do 47 | subscription = subscription_fixture() 48 | assert {:error, %Ecto.Changeset{}} = Subscriptions.update_subscription(subscription, @invalid_attrs) 49 | assert subscription == Subscriptions.get_subscription!(subscription.id) 50 | end 51 | 52 | test "delete_subscription/1 deletes the subscription" do 53 | subscription = subscription_fixture() 54 | assert {:ok, %Subscription{}} = Subscriptions.delete_subscription(subscription) 55 | assert_raise Ecto.NoResultsError, fn -> Subscriptions.get_subscription!(subscription.id) end 56 | end 57 | 58 | test "change_subscription/1 returns a subscription changeset" do 59 | subscription = subscription_fixture() 60 | assert %Ecto.Changeset{} = Subscriptions.change_subscription(subscription) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/deer_storage/subscriptions/deer_table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Subscriptions.DeerTableTest do 2 | use DeerStorage.DataCase 3 | import DeerStorage.DeerFixtures 4 | import Ecto.Changeset, only: [fetch_field!: 2] 5 | 6 | alias DeerStorage.Subscriptions.DeerTable 7 | import DeerTable, only: [changeset: 2, move_column_to_index: 3] 8 | 9 | describe "DeerTable.move_column_to_index/3" do 10 | setup do 11 | subscription = create_valid_subscription_with_tables(1, 10) 12 | attrs = %{name: "example table", 13 | deer_columns: [%{name: "first table"}, 14 | %{name: "second table"}, 15 | %{name: "third table"}, 16 | %{name: "fourth table"}]} 17 | 18 | changeset = changeset(%DeerTable{}, attrs) 19 | 20 | {:ok, subscription: subscription, table_changeset: changeset} 21 | end 22 | 23 | test "moves column to specific index: 1->2", %{table_changeset: table_changeset} do 24 | new_deer_columns = table_changeset 25 | |> move_column_to_index(1, 2) 26 | |> fetch_field!(:deer_columns) 27 | 28 | assert [%{id: nil, name: "first table"}, %{id: nil, name: "third table"}, %{id: nil, name: "second table"} | _] = new_deer_columns 29 | end 30 | 31 | test "moves column to specific index: 2->1", %{table_changeset: table_changeset} do 32 | new_deer_columns = table_changeset 33 | |> move_column_to_index(2, 1) 34 | |> fetch_field!(:deer_columns) 35 | 36 | assert [%{id: nil, name: "first table"}, %{id: nil, name: "third table"}, %{id: nil, name: "second table"} | _] = new_deer_columns 37 | end 38 | 39 | test "moves column to specific index: 0->-1", %{table_changeset: table_changeset} do 40 | new_deer_columns = table_changeset 41 | |> move_column_to_index(0, -1) 42 | |> fetch_field!(:deer_columns) 43 | 44 | assert [%{id: nil, name: "second table"}, %{id: nil, name: "third table"}, %{id: nil, name: "fourth table"}, %{id: nil, name: "first table"}] = new_deer_columns 45 | end 46 | 47 | test "moves column to specific index: 3->4", %{table_changeset: table_changeset} do 48 | new_deer_columns = table_changeset 49 | |> move_column_to_index(3, 4) 50 | |> fetch_field!(:deer_columns) 51 | 52 | assert [%{id: nil, name: "fourth table"}, %{id: nil, name: "first table"}, %{id: nil, name: "second table"}, %{id: nil, name: "third table"}] = new_deer_columns 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/deer_storage_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.PageControllerTest do 2 | use DeerStorageWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "DeerStorage" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/deer_storage_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ErrorViewTest do 2 | use DeerStorageWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(DeerStorageWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(DeerStorageWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/deer_storage_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.LayoutViewTest do 2 | use DeerStorageWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/deer_storage_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.PageViewTest do 2 | use DeerStorageWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | 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 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint DeerStorageWeb.Endpoint 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DeerStorage.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(DeerStorage.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | 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 | alias DeerStorageWeb.EtsCacheMock 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | # Import conveniences for testing with connections 22 | import Plug.Conn 23 | import Phoenix.ConnTest 24 | 25 | alias DeerStorageWeb.Router.Helpers, as: Routes 26 | 27 | # The default endpoint for testing 28 | @endpoint DeerStorageWeb.Endpoint 29 | end 30 | end 31 | 32 | setup tags do 33 | EtsCacheMock.init() 34 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DeerStorage.Repo) 35 | 36 | unless tags[:async] do 37 | Ecto.Adapters.SQL.Sandbox.mode(DeerStorage.Repo, {:shared, self()}) 38 | end 39 | 40 | {:ok, conn: Phoenix.ConnTest.build_conn(), ets: EtsCacheMock} 41 | end 42 | 43 | setup %{conn: conn}, do: {:ok, conn: conn |> Pow.Plug.put_config([otp_app: :deer_storage])} 44 | end 45 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.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 DeerStorage.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import DeerStorage.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DeerStorage.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(DeerStorage.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transforms changeset errors into 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 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 49 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/support/deer_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.DeerFixtures do 2 | alias DeerStorage.DeerRecords 3 | alias DeerStorage.Subscriptions 4 | alias DeerStorage.Subscriptions.Subscription 5 | 6 | def create_valid_subscription_with_tables(tables_count \\ 1, columns_count \\ 1) do 7 | deer_tables = Enum.map(1..tables_count, fn _ -> 8 | deer_columns = Enum.map(1..columns_count, fn _ -> %{name: Faker.Commerce.product_name_product()} end) 9 | 10 | %{name: Faker.Team.name(), deer_columns: deer_columns} 11 | end) 12 | 13 | {:ok, subscription} = Subscriptions.create_subscription(%{name: Faker.Company.catch_phrase()}) 14 | {:ok, subscription_with_deer} = Subscriptions.update_subscription_deer(subscription, %{deer_tables: deer_tables}) 15 | 16 | subscription_with_deer 17 | end 18 | 19 | def create_valid_records_for_subscription(%Subscription{deer_tables: deer_tables} = subscription, records_per_table_count \\ 1) do 20 | grouped_records = Enum.map(deer_tables, fn %{id: table_id, deer_columns: deer_columns} -> 21 | Enum.map(1..records_per_table_count, fn n -> 22 | fields_attrs = Enum.map(deer_columns, fn %{id: column_id} -> 23 | %{deer_column_id: column_id, content: "Content #{n}"} 24 | end) 25 | {:ok, record} = DeerRecords.create_record(subscription, %{deer_table_id: table_id, deer_fields: fields_attrs}) 26 | record 27 | end) 28 | end) 29 | 30 | List.flatten(grouped_records) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/support/ets_cache_mock.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorageWeb.EtsCacheMock do 2 | @tab __MODULE__ 3 | 4 | def init, do: :ets.new(@tab, [:ordered_set, :protected, :named_table]) 5 | 6 | def get(config, key) do 7 | ets_key = ets_key(config, key) 8 | 9 | @tab 10 | |> :ets.lookup(ets_key) 11 | |> case do 12 | [{^ets_key, value} | _rest] -> value 13 | [] -> :not_found 14 | end 15 | end 16 | 17 | def delete(config, key) do 18 | :ets.delete(@tab, ets_key(config, key)) 19 | 20 | :ok 21 | end 22 | 23 | def put(config, record_or_records) do 24 | records = List.wrap(record_or_records) 25 | ets_records = Enum.map(records, fn {key, value} -> 26 | {ets_key(config, key), value} 27 | end) 28 | 29 | :ets.insert(@tab, ets_records) 30 | end 31 | 32 | def all(config, match) do 33 | ets_key_match = ets_key(config, match) 34 | 35 | @tab 36 | |> :ets.select([{{ets_key_match, :_}, [], [:"$_"]}]) 37 | |> Enum.map(fn {[_namespace | keys], value} -> {keys, value} end) 38 | end 39 | 40 | defp ets_key(config, key) do 41 | [Keyword.get(config, :namespace, "cache") | List.wrap(key)] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/support/fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Fixtures do 2 | alias DeerStorage.{Repo, Users, Subscriptions.Subscription} 3 | 4 | def random_subscription_attrs(), do: %Subscription{name: Faker.Person.name()} 5 | def random_user_attrs(), do: %{email: Faker.Internet.safe_email(), 6 | name: Faker.Person.name(), 7 | password: "secret123", 8 | email_confirmed_at: DateTime.utc_now, 9 | email_confirmation_token: nil, 10 | locale: "pl"} 11 | 12 | def create_expired_subscription(attrs), do: Subscription.admin_changeset(attrs, %{expires_on: Date.add(Date.utc_today, -1)}) |> Repo.insert 13 | def create_valid_subscription(attrs), do: Subscription.admin_changeset(attrs, %{expires_on: Date.add(Date.utc_today, 1)}) |> Repo.insert 14 | 15 | def create_valid_user_with_subscription(user_attrs \\ random_user_attrs(), subscription_attrs \\ random_subscription_attrs()) do 16 | {:ok, subscription} = create_valid_subscription(subscription_attrs) 17 | {:ok, user} = Users.admin_create_user(user_attrs |> Map.merge(%{last_used_subscription_id: subscription.id})) 18 | 19 | user 20 | end 21 | 22 | def create_valid_user_with_unconfirmed_email(attrs \\ random_user_attrs(), subscription_attrs \\ random_subscription_attrs()) do 23 | {:ok, subscription} = create_valid_subscription(subscription_attrs) 24 | {:ok, user} = Users.admin_create_user(attrs |> Map.merge(%{last_used_subscription_id: subscription.id, email_confirmed_at: nil, email_confirmation_token: "ABC"})) 25 | 26 | user 27 | end 28 | 29 | def create_user_with_expired_subscription(attrs \\ random_user_attrs(), subscription_attrs \\ random_subscription_attrs()) do 30 | {:ok, subscription} = create_expired_subscription(subscription_attrs) 31 | {:ok, user} = Users.admin_create_user(attrs |> Map.merge(%{last_used_subscription_id: subscription.id})) 32 | 33 | user 34 | end 35 | 36 | def create_user_without_subscription(attrs \\ random_user_attrs()) do 37 | {:ok, user} = Users.admin_create_user(attrs |> Map.merge(%{last_used_subscription_id: nil})) 38 | user 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/session_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Test.SessionHelpers do 2 | import DeerStorage.Users.UserSessionUtils, only: [maybe_put_subscription_into_session: 1] 3 | 4 | def assign_user_to_session(conn, user) do 5 | conn |> Plug.Test.init_test_session(%{}) |> Pow.Plug.assign_current_user(user, []) |> maybe_put_subscription_into_session 6 | end 7 | 8 | def assign_locale_to_session(conn, locale) do 9 | conn |> Plug.Test.init_test_session(%{}) |> Plug.Conn.put_session("locale", locale) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/support/test_pow_message_verifier.ex: -------------------------------------------------------------------------------- 1 | defmodule DeerStorage.Test.Pow.MessageVerifier do 2 | def sign(_conn, salt, message, _config), 3 | do: "signed.#{salt}.#{message}" 4 | 5 | def verify(_conn, salt, message, _config) do 6 | prepend = "signed." <> salt <> "." 7 | 8 | case String.replace(message, prepend, "") do 9 | ^message -> :error 10 | message -> {:ok, message} 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(DeerStorage.Repo, :manual) 3 | --------------------------------------------------------------------------------