├── .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 |
16 |
17 |
18 |
24 |
25 |
26 |
27 | <%= @current_shared_link %>
28 |
29 |
30 |
31 |
37 |
38 |
39 |
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 |
16 | <% end %>
17 |
--------------------------------------------------------------------------------
/lib/deer_storage_web/templates/admin/subscription/edit.html.eex:
--------------------------------------------------------------------------------
1 | <%= render "_header.html" %>
2 |
3 |
4 |
5 |
6 | <%= link gettext("All databases"), to: Routes.admin_subscription_path(@conn, :index) %>
7 |
8 |
9 | <%= link @subscription.name, to: Routes.admin_subscription_path(@conn, :show, @subscription) %>
10 |
11 |
12 | <%= link gettext("Edit"), to: Routes.admin_subscription_path(@conn, :edit, @subscription) %>
13 |
14 |
15 |
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 |
3 |
4 |
5 | <%= link gettext("All databases"), to: Routes.admin_subscription_path(@conn, :index) %>
6 |
7 |
8 | <%= link gettext("New database"), to: Routes.admin_subscription_path(@conn, :new) %>
9 |
10 |
11 |
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 |
15 | <% end %>
16 |
--------------------------------------------------------------------------------
/lib/deer_storage_web/templates/admin/user/edit.html.eex:
--------------------------------------------------------------------------------
1 | <%= render "_header.html" %>
2 |
3 |
4 |
5 |
6 | <%= link gettext("All users"), to: Routes.admin_user_path(@conn, :index) %>
7 |
8 |
9 | <%= if @user.last_used_subscription_id == nil do %>
10 | <%= gettext("empty") %>
11 | <% else %>
12 | <%= link @user.last_used_subscription.name, to: Routes.admin_subscription_path(@conn, :show, @user.last_used_subscription) %>
13 | <% end %>
14 |
15 |
16 | <%= link @user.name, to: Routes.admin_user_path(@conn, :show, @user) %>
17 |
18 |
19 | <%= link gettext("Edit"), to: Routes.admin_user_path(@conn, :edit, @user) %>
20 |
21 |
22 |
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 |
4 |
5 |
6 | <%= link gettext("All users"), to: Routes.admin_user_path(@conn, :index) %>
7 |
8 |
9 | <%= link gettext("New User"), to: Routes.admin_user_path(@conn, :new) %>
10 |
11 |
12 |
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 |
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 |
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 |
<%= column_name %>
4 |
5 |
11 |
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/lib/deer_storage_web/templates/deer_record/preview_modal_document.html.leex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
16 |
17 |
24 |
25 |
26 |
27 | <%= maybe_shrink_filename(assigns.deer_file.original_filename, 30) %>
28 |
29 |
<%= gettext("Close") %>
30 |
31 |
--------------------------------------------------------------------------------
/lib/deer_storage_web/templates/deer_record/preview_modal_image.html.leex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | <%= maybe_shrink_filename(assigns.deer_file.original_filename, 30) %>
22 |
23 |
<%= gettext("Close") %>
24 |
25 |
--------------------------------------------------------------------------------
/lib/deer_storage_web/templates/deer_record/preview_modal_video.html.leex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
16 |
17 |
18 | Your browser does not support the video tag.
19 |
20 |
21 |
22 |
23 | <%= maybe_shrink_filename(assigns.deer_file.original_filename, 30) %>
24 |
25 |
<%= gettext("Close") %>
26 |
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 |
2 |
3 | <%= link(
4 | gettext("Admin Panel"),
5 | to: Routes.admin_live_path(@conn, DeerStorageWeb.Admin.DashboardLive.Index),
6 | class: "navbar-item") %>
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
31 |
32 |
33 |
36 |
--------------------------------------------------------------------------------
/lib/deer_storage_web/templates/layout/navigation_guest.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 🦌 <%= gettext("DeerStorage") %>
7 |
8 |
9 |
10 |
11 |
12 |
13 | <% current_locale = Gettext.get_locale(DeerStorageWeb.Gettext) %>
14 |
15 | <%= form_for DeerStorageWeb.Endpoint, Routes.change_language_path(DeerStorageWeb.Endpoint, :change_language), fn _f -> %>
16 |
17 | <%= Enum.map(available_languages_and_locales(), fn {language, ^current_locale} -> %>
18 | <%= language %>
19 | <% {language, locale} -> %>
20 | <%= language %>
21 | <% end) %>
22 |
23 | <% end %>
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
49 |
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 | <%= get_flash(@conn, :error) %>
18 | <%= get_flash(@conn, :info) %>
19 | <%= get_flash(@conn, :notice) %>
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 | <%= get_flash(@conn, :error) %>
16 | <%= get_flash(@conn, :info) %>
17 | <%= get_flash(@conn, :notice) %>
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 |
33 | <%= gettext("You are an administrator") %>
34 |
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 |
49 | <%= available_subscription.name %>
50 |
51 |
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 |
57 |
58 | <% end %>
59 |
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 | <%= gettext("Name and surname") %>
29 | <%= gettext("E-mail") %>
30 | <%= gettext("Can manage users?") %>
31 | <%= gettext("Other") %>
32 |
33 |
34 |
35 | <%= for {user, user_subscription_link} <- @users do %>
36 |
37 |
38 | <%= if user.name do %>
39 | <%= user.name %>
40 | <% else %>
41 | <%= gettext("(empty)") %>
42 | <% end %>
43 |
44 | <%= user.email %>
45 | <%= link_to_toggle_permission(user, user_subscription_link, :permission_to_manage_users, @current_user_can_manage_users, @current_user) %>
46 |
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 |
55 |
56 | <% end %>
57 |
58 |
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 |
--------------------------------------------------------------------------------