From 7f5648452731afc1c67221430fd1f3ecac4fc7cd Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sat, 18 Jul 2026 16:52:16 +0300 Subject: [PATCH] feat: implement Who Need Help MVP --- .dockerignore | 59 ++ .env.example | 19 + .formatter.exs | 6 + .gitattributes | 3 + .gitignore | 62 ++ Dockerfile | 145 ++++ README.md | 178 ++++ android/.dockerignore | 6 + android/Dockerfile | 109 +++ android/README.md | 70 ++ android/app/build.gradle.kts | 115 +++ android/app/proguard-rules.pro | 1 + .../debug/res/xml/network_security_config.xml | 8 + android/app/src/main/AndroidManifest.xml | 27 + .../org/whoneedhelp/mobile/MainActivity.java | 330 ++++++++ .../org/whoneedhelp/mobile/TrustedOrigin.java | 87 ++ .../app/src/main/res/drawable/ic_launcher.xml | 13 + android/app/src/main/res/values/colors.xml | 5 + android/app/src/main/res/values/strings.xml | 8 + android/app/src/main/res/values/styles.xml | 11 + .../main/res/xml/data_extraction_rules.xml | 17 + .../main/res/xml/network_security_config.xml | 4 + .../whoneedhelp/mobile/TrustedOriginTest.java | 45 + android/build.gradle.kts | 3 + android/gradle.properties | 4 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 10 + android/gradlew | 248 ++++++ android/gradlew.bat | 82 ++ android/settings.gradle.kts | 18 + assets/css/app.css | 122 +++ assets/js/app.js | 91 ++ assets/js/hooks.js | 108 +++ assets/package-lock.json | 250 ++++++ assets/package.json | 7 + assets/tsconfig.json | 32 + assets/vendor/heroicons.js | 43 + assets/vendor/topbar.js | 138 +++ compose.yaml | 118 +++ config/config.exs | 101 +++ config/dev.exs | 78 ++ config/prod.exs | 33 + config/runtime.exs | 187 +++++ config/test.exs | 45 + deploy/helm/who-need-help/Chart.yaml | 7 + .../helm/who-need-help/templates/_helpers.tpl | 26 + .../who-need-help/templates/deployments.yaml | 121 +++ .../helm/who-need-help/templates/ingress.yaml | 35 + .../who-need-help/templates/migrate-job.yaml | 40 + deploy/helm/who-need-help/templates/pdb.yaml | 25 + .../helm/who-need-help/templates/secret.yaml | 18 + .../who-need-help/templates/services.yaml | 34 + deploy/helm/who-need-help/values-kind.yaml | 18 + deploy/helm/who-need-help/values.yaml | 51 ++ deploy/kind/cluster.yaml | 12 + deploy/kind/dependencies.yaml | 69 ++ docs/architecture.md | 171 ++++ docs/category-moderation-output.schema.json | 26 + ...001-postgresql-postgis-over-spacetimedb.md | 54 ++ docs/product-spec.md | 118 +++ docs/trust-safety.md | 114 +++ docs/verification.md | 89 ++ lib/who_need_help.ex | 9 + lib/who_need_help/accounts.ex | 428 ++++++++++ lib/who_need_help/accounts/scope.ex | 33 + lib/who_need_help/accounts/social_identity.ex | 30 + lib/who_need_help/accounts/user.ex | 219 +++++ lib/who_need_help/accounts/user_notifier.ex | 84 ++ lib/who_need_help/accounts/user_token.ex | 158 ++++ lib/who_need_help/application.ex | 42 + lib/who_need_help/catalog.ex | 297 +++++++ lib/who_need_help/catalog/category.ex | 120 +++ .../catalog/category_proposal.ex | 37 + lib/who_need_help/catalog/category_vote.ex | 20 + lib/who_need_help/catalog_moderation.ex | 34 + lib/who_need_help/help.ex | 528 ++++++++++++ lib/who_need_help/help/assignment.ex | 49 ++ lib/who_need_help/help/help_request.ex | 102 +++ lib/who_need_help/mailer.ex | 3 + lib/who_need_help/messaging.ex | 64 ++ lib/who_need_help/messaging/message.ex | 22 + lib/who_need_help/postgrex_types.ex | 5 + lib/who_need_help/release.ex | 89 ++ lib/who_need_help/repo.ex | 5 + lib/who_need_help/tracking.ex | 289 +++++++ lib/who_need_help/tracking/position.ex | 37 + .../tracking/tracking_session.ex | 46 + lib/who_need_help/trust.ex | 695 ++++++++++++++++ lib/who_need_help/trust/abuse_signal.ex | 44 + lib/who_need_help/trust/audit_event.ex | 22 + lib/who_need_help/trust/block.ex | 21 + lib/who_need_help/trust/rate_limit_bucket.ex | 25 + lib/who_need_help/trust/rate_limiter.ex | 84 ++ lib/who_need_help/trust/report.ex | 57 ++ lib/who_need_help/trust/review.ex | 27 + lib/who_need_help/workers/expire_requests.ex | 63 ++ lib/who_need_help_web.ex | 115 +++ .../components/core_components.ex | 505 +++++++++++ lib/who_need_help_web/components/layouts.ex | 193 +++++ .../components/layouts/root.html.heex | 80 ++ .../controllers/error_html.ex | 24 + .../controllers/error_json.ex | 21 + .../controllers/feedback_controller.ex | 9 + .../controllers/feedback_html.ex | 4 + .../controllers/feedback_html/show.html.heex | 29 + .../controllers/health_controller.ex | 12 + .../controllers/page_controller.ex | 11 + .../controllers/page_html.ex | 10 + .../controllers/page_html/home.html.heex | 78 ++ .../controllers/page_html/safety.html.heex | 65 ++ .../user_registration_controller.ex | 46 + .../controllers/user_registration_html.ex | 5 + .../user_registration_html/new.html.heex | 57 ++ .../controllers/user_session_controller.ex | 100 +++ .../controllers/user_session_html.ex | 9 + .../user_session_html/confirm.html.heex | 59 ++ .../user_session_html/new.html.heex | 73 ++ .../controllers/user_settings_controller.ex | 77 ++ .../controllers/user_settings_html.ex | 5 + .../user_settings_html/edit.html.heex | 49 ++ lib/who_need_help_web/endpoint.ex | 55 ++ lib/who_need_help_web/gettext.ex | 25 + .../live/category_proposal_live.ex | 111 +++ .../live/leaderboard_live.ex | 63 ++ lib/who_need_help_web/live/moderation_live.ex | 411 +++++++++ lib/who_need_help_web/live/profile_live.ex | 266 ++++++ .../live/request_live/index.ex | 188 +++++ .../live/request_live/new.ex | 285 +++++++ .../live/request_live/show.ex | 784 ++++++++++++++++++ lib/who_need_help_web/locale.ex | 19 + lib/who_need_help_web/presence.ex | 5 + lib/who_need_help_web/router.ex | 102 +++ lib/who_need_help_web/telemetry.ex | 93 +++ lib/who_need_help_web/user_auth.ex | 285 +++++++ mix.exs | 105 +++ mix.lock | 53 ++ priv/gettext/default.pot | 100 +++ priv/gettext/en/LC_MESSAGES/default.po | 100 +++ priv/gettext/en/LC_MESSAGES/errors.po | 112 +++ priv/gettext/errors.pot | 109 +++ priv/gettext/ru/LC_MESSAGES/default.po | 100 +++ priv/gettext/ru/LC_MESSAGES/errors.po | 111 +++ priv/gettext/uk/LC_MESSAGES/default.po | 100 +++ priv/gettext/uk/LC_MESSAGES/errors.po | 111 +++ priv/repo/migrations/.formatter.exs | 4 + ...0260718003125_create_users_auth_tables.exs | 32 + ...0260718003613_create_mutual_aid_domain.exs | 230 +++++ .../20260718003614_add_oban_jobs.exs | 6 + ...2612_add_trust_and_moderation_controls.exs | 97 +++ ...9_add_structured_data_to_help_requests.exs | 9 + ...add_blocks_blocked_id_blocker_id_index.exs | 7 + priv/repo/seeds.exs | 11 + priv/static/favicon.ico | Bin 0 -> 152 bytes priv/static/images/logo.svg | 6 + priv/static/manifest.webmanifest | 18 + priv/static/robots.txt | 5 + priv/static/sw.js | 33 + rel/env.sh.eex | 10 + rel/overlays/bin/await_migrations | 5 + rel/overlays/bin/migrate | 5 + rel/overlays/bin/migrate.bat | 1 + rel/overlays/bin/server | 5 + rel/overlays/bin/server.bat | 2 + scripts/android-build.sh | 22 + scripts/bootstrap-admin.sh | 48 ++ scripts/bootstrap-kubernetes-tools.sh | 69 ++ scripts/codex-review-categories.sh | 44 + scripts/compose-up.sh | 9 + scripts/kind-up.sh | 45 + scripts/test.sh | 16 + scripts/verify-realtime-cluster.sh | 88 ++ test/support/conn_case.ex | 79 ++ test/support/data_case.ex | 58 ++ test/support/fixtures/accounts_fixtures.ex | 91 ++ test/test_helper.exs | 2 + test/who_need_help/accounts_test.exs | 397 +++++++++ test/who_need_help/mutual_aid_flow_test.exs | 164 ++++ test/who_need_help/trust_safety_test.exs | 290 +++++++ .../controllers/error_html_test.exs | 15 + .../controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 27 + .../user_registration_controller_test.exs | 50 ++ .../user_session_controller_test.exs | 220 +++++ .../user_settings_controller_test.exs | 148 ++++ .../live/mutual_aid_live_test.exs | 181 ++++ test/who_need_help_web/user_auth_test.exs | 293 +++++++ 186 files changed, 16045 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .formatter.exs create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 android/.dockerignore create mode 100644 android/Dockerfile create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/debug/res/xml/network_security_config.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java create mode 100644 android/app/src/main/java/org/whoneedhelp/mobile/TrustedOrigin.java create mode 100644 android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/app/src/test/java/org/whoneedhelp/mobile/TrustedOriginTest.java create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 assets/css/app.css create mode 100644 assets/js/app.js create mode 100644 assets/js/hooks.js create mode 100644 assets/package-lock.json create mode 100644 assets/package.json create mode 100644 assets/tsconfig.json create mode 100644 assets/vendor/heroicons.js create mode 100644 assets/vendor/topbar.js create mode 100644 compose.yaml create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/runtime.exs create mode 100644 config/test.exs create mode 100644 deploy/helm/who-need-help/Chart.yaml create mode 100644 deploy/helm/who-need-help/templates/_helpers.tpl create mode 100644 deploy/helm/who-need-help/templates/deployments.yaml create mode 100644 deploy/helm/who-need-help/templates/ingress.yaml create mode 100644 deploy/helm/who-need-help/templates/migrate-job.yaml create mode 100644 deploy/helm/who-need-help/templates/pdb.yaml create mode 100644 deploy/helm/who-need-help/templates/secret.yaml create mode 100644 deploy/helm/who-need-help/templates/services.yaml create mode 100644 deploy/helm/who-need-help/values-kind.yaml create mode 100644 deploy/helm/who-need-help/values.yaml create mode 100644 deploy/kind/cluster.yaml create mode 100644 deploy/kind/dependencies.yaml create mode 100644 docs/architecture.md create mode 100644 docs/category-moderation-output.schema.json create mode 100644 docs/decisions/0001-postgresql-postgis-over-spacetimedb.md create mode 100644 docs/product-spec.md create mode 100644 docs/trust-safety.md create mode 100644 docs/verification.md create mode 100644 lib/who_need_help.ex create mode 100644 lib/who_need_help/accounts.ex create mode 100644 lib/who_need_help/accounts/scope.ex create mode 100644 lib/who_need_help/accounts/social_identity.ex create mode 100644 lib/who_need_help/accounts/user.ex create mode 100644 lib/who_need_help/accounts/user_notifier.ex create mode 100644 lib/who_need_help/accounts/user_token.ex create mode 100644 lib/who_need_help/application.ex create mode 100644 lib/who_need_help/catalog.ex create mode 100644 lib/who_need_help/catalog/category.ex create mode 100644 lib/who_need_help/catalog/category_proposal.ex create mode 100644 lib/who_need_help/catalog/category_vote.ex create mode 100644 lib/who_need_help/catalog_moderation.ex create mode 100644 lib/who_need_help/help.ex create mode 100644 lib/who_need_help/help/assignment.ex create mode 100644 lib/who_need_help/help/help_request.ex create mode 100644 lib/who_need_help/mailer.ex create mode 100644 lib/who_need_help/messaging.ex create mode 100644 lib/who_need_help/messaging/message.ex create mode 100644 lib/who_need_help/postgrex_types.ex create mode 100644 lib/who_need_help/release.ex create mode 100644 lib/who_need_help/repo.ex create mode 100644 lib/who_need_help/tracking.ex create mode 100644 lib/who_need_help/tracking/position.ex create mode 100644 lib/who_need_help/tracking/tracking_session.ex create mode 100644 lib/who_need_help/trust.ex create mode 100644 lib/who_need_help/trust/abuse_signal.ex create mode 100644 lib/who_need_help/trust/audit_event.ex create mode 100644 lib/who_need_help/trust/block.ex create mode 100644 lib/who_need_help/trust/rate_limit_bucket.ex create mode 100644 lib/who_need_help/trust/rate_limiter.ex create mode 100644 lib/who_need_help/trust/report.ex create mode 100644 lib/who_need_help/trust/review.ex create mode 100644 lib/who_need_help/workers/expire_requests.ex create mode 100644 lib/who_need_help_web.ex create mode 100644 lib/who_need_help_web/components/core_components.ex create mode 100644 lib/who_need_help_web/components/layouts.ex create mode 100644 lib/who_need_help_web/components/layouts/root.html.heex create mode 100644 lib/who_need_help_web/controllers/error_html.ex create mode 100644 lib/who_need_help_web/controllers/error_json.ex create mode 100644 lib/who_need_help_web/controllers/feedback_controller.ex create mode 100644 lib/who_need_help_web/controllers/feedback_html.ex create mode 100644 lib/who_need_help_web/controllers/feedback_html/show.html.heex create mode 100644 lib/who_need_help_web/controllers/health_controller.ex create mode 100644 lib/who_need_help_web/controllers/page_controller.ex create mode 100644 lib/who_need_help_web/controllers/page_html.ex create mode 100644 lib/who_need_help_web/controllers/page_html/home.html.heex create mode 100644 lib/who_need_help_web/controllers/page_html/safety.html.heex create mode 100644 lib/who_need_help_web/controllers/user_registration_controller.ex create mode 100644 lib/who_need_help_web/controllers/user_registration_html.ex create mode 100644 lib/who_need_help_web/controllers/user_registration_html/new.html.heex create mode 100644 lib/who_need_help_web/controllers/user_session_controller.ex create mode 100644 lib/who_need_help_web/controllers/user_session_html.ex create mode 100644 lib/who_need_help_web/controllers/user_session_html/confirm.html.heex create mode 100644 lib/who_need_help_web/controllers/user_session_html/new.html.heex create mode 100644 lib/who_need_help_web/controllers/user_settings_controller.ex create mode 100644 lib/who_need_help_web/controllers/user_settings_html.ex create mode 100644 lib/who_need_help_web/controllers/user_settings_html/edit.html.heex create mode 100644 lib/who_need_help_web/endpoint.ex create mode 100644 lib/who_need_help_web/gettext.ex create mode 100644 lib/who_need_help_web/live/category_proposal_live.ex create mode 100644 lib/who_need_help_web/live/leaderboard_live.ex create mode 100644 lib/who_need_help_web/live/moderation_live.ex create mode 100644 lib/who_need_help_web/live/profile_live.ex create mode 100644 lib/who_need_help_web/live/request_live/index.ex create mode 100644 lib/who_need_help_web/live/request_live/new.ex create mode 100644 lib/who_need_help_web/live/request_live/show.ex create mode 100644 lib/who_need_help_web/locale.ex create mode 100644 lib/who_need_help_web/presence.ex create mode 100644 lib/who_need_help_web/router.ex create mode 100644 lib/who_need_help_web/telemetry.ex create mode 100644 lib/who_need_help_web/user_auth.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 priv/gettext/default.pot create mode 100644 priv/gettext/en/LC_MESSAGES/default.po create mode 100644 priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 priv/gettext/errors.pot create mode 100644 priv/gettext/ru/LC_MESSAGES/default.po create mode 100644 priv/gettext/ru/LC_MESSAGES/errors.po create mode 100644 priv/gettext/uk/LC_MESSAGES/default.po create mode 100644 priv/gettext/uk/LC_MESSAGES/errors.po create mode 100644 priv/repo/migrations/.formatter.exs create mode 100644 priv/repo/migrations/20260718003125_create_users_auth_tables.exs create mode 100644 priv/repo/migrations/20260718003613_create_mutual_aid_domain.exs create mode 100644 priv/repo/migrations/20260718003614_add_oban_jobs.exs create mode 100644 priv/repo/migrations/20260718012612_add_trust_and_moderation_controls.exs create mode 100644 priv/repo/migrations/20260718013929_add_structured_data_to_help_requests.exs create mode 100644 priv/repo/migrations/20260718114233_add_blocks_blocked_id_blocker_id_index.exs create mode 100644 priv/repo/seeds.exs create mode 100644 priv/static/favicon.ico create mode 100644 priv/static/images/logo.svg create mode 100644 priv/static/manifest.webmanifest create mode 100644 priv/static/robots.txt create mode 100644 priv/static/sw.js create mode 100644 rel/env.sh.eex create mode 100755 rel/overlays/bin/await_migrations create mode 100755 rel/overlays/bin/migrate create mode 100755 rel/overlays/bin/migrate.bat create mode 100755 rel/overlays/bin/server create mode 100755 rel/overlays/bin/server.bat create mode 100755 scripts/android-build.sh create mode 100755 scripts/bootstrap-admin.sh create mode 100755 scripts/bootstrap-kubernetes-tools.sh create mode 100755 scripts/codex-review-categories.sh create mode 100755 scripts/compose-up.sh create mode 100755 scripts/kind-up.sh create mode 100755 scripts/test.sh create mode 100755 scripts/verify-realtime-cluster.sh create mode 100644 test/support/conn_case.ex create mode 100644 test/support/data_case.ex create mode 100644 test/support/fixtures/accounts_fixtures.ex create mode 100644 test/test_helper.exs create mode 100644 test/who_need_help/accounts_test.exs create mode 100644 test/who_need_help/mutual_aid_flow_test.exs create mode 100644 test/who_need_help/trust_safety_test.exs create mode 100644 test/who_need_help_web/controllers/error_html_test.exs create mode 100644 test/who_need_help_web/controllers/error_json_test.exs create mode 100644 test/who_need_help_web/controllers/page_controller_test.exs create mode 100644 test/who_need_help_web/controllers/user_registration_controller_test.exs create mode 100644 test/who_need_help_web/controllers/user_session_controller_test.exs create mode 100644 test/who_need_help_web/controllers/user_settings_controller_test.exs create mode 100644 test/who_need_help_web/live/mutual_aid_live_test.exs create mode 100644 test/who_need_help_web/user_auth_test.exs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..418e1df --- /dev/null +++ b/.dockerignore @@ -0,0 +1,59 @@ +# This file excludes paths from the Docker build context. +# +# By default, Docker's build context includes all files (and folders) in the +# current directory. Even if a file isn't copied into the container it is still sent to +# the Docker daemon. +# +# There are multiple reasons to exclude files from the build context: +# +# 1. Prevent nested folders from being copied into the container (ex: exclude +# /assets/node_modules when copying /assets) +# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) +# 3. Avoid sending files containing sensitive information +# +# More information on using .dockerignore is available here: +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +.dockerignore + +# Ignore git, but keep git HEAD and refs to access current commit hash if needed: +# +# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat +# d0b8727759e1e0e7aa3d41707d12376e373d5ecc +.git +!.git/HEAD +!.git/refs + +# Common development/test artifacts +/cover/ +/doc/ +/tmp/ +.elixir_ls + +# Mix artifacts +/_build/ +/deps/ +*.ez + +# Generated on crash by the VM +erl_crash.dump + +# Static artifacts - These should be fetched and built inside the Docker image +# https://phoenix.hexdocs.pm/Mix.Tasks.Phx.Gen.Release.html#module-docker +/assets/node_modules/ +/priv/static/assets/ +/priv/static/cache_manifest.json +/android/.gradle/ +/android/app/build/ +/android/build/ +/android/dist/ +/android/dist-*/ +/.tools/ +/.playwright-cli/ +/output/ + +# Local configuration can contain deployment credentials and must not be sent +# to Docker when Compose builds from the repository root. +/.env +/.env.* +!/.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e03719d --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Local Compose defaults work without this file. Replace every value before a public deployment. +HTTP_PORT=4010 +MAILPIT_PORT=8027 +PHX_HOST=localhost +# Android debug builds compile this origin into BuildConfig. The Docker +# emulator uses adb reverse to expose the local Compose proxy on loopback. +WNH_DEBUG_BASE_URL=http://localhost:4010 +# Public raster tile template used by MapLibre. Use a provider whose policy and +# capacity match the deployment before a public launch. +MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png +# Compose derives PHX_URL_PORT from HTTP_PORT and uses HTTP for local development. +POOL_SIZE=10 +SECRET_KEY_BASE=generate-with-mix-phx-gen-secret +HANDOVER_SECRET=generate-an-independent-random-secret +RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie +CODEX_SESSION_ID=copy-the-main-local-codex-session-id +# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved. +# Shape: {"action_name":{"limit":POSITIVE_INTEGER,"window_seconds":POSITIVE_INTEGER}} +RATE_LIMIT_POLICIES_JSON={} diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..699ceba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto +*.bat text eol=crlf +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e554e2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +who_need_help-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ +/.tools/ + +# Local environment files can contain deployment credentials. Keep only the +# documented template in Git. +/.env +/.env.* +!/.env.example + +# Local browser automation state and generated verification artifacts. +/.playwright-cli/ +/output/ + +# Android build output and local SDK configuration. +/android/.gradle/ +/android/.kotlin/ +/android/.idea/ +/android/.cxx/ +/android/app/build/ +/android/build/ +/android/dist/ +/android/dist-*/ +/android/local.properties +*.apk +*.aab +*.jks +*.keystore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e8fa4f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,145 @@ +# This file is based on these images: +# +# - https://hub.docker.com/r/hexpm/elixir/tags - for the builder image +# E.g.: docker.io/hexpm/elixir:1.18.4-erlang-27.3.4.13-debian-trixie-20260623-slim +# - https://hub.docker.com/_/debian/tags?name=trixie-20260623-slim - for the runner image +# E.g.: docker.io/debian:trixie-20260623-slim +# +# Find builder and runner images on Docker Hub or on Hex's Build Server (Bob). +# We recommend using Bob's Web UI to find recent tags: +# +# - https://bob.hex.pm/docker +# +# We suggest using the same Debian version for both the builder and runner images. +# +# We suggest Debian/Ubuntu instead of Alpine to avoid production compatibility issues +# (such as DNS resolution failures, and dynamically linked NIFs/precompiled binaries). +# +# For finding packages in Debian, search on https://packages.debian.org/. + +ARG ELIXIR_VERSION=1.18.4 +ARG OTP_VERSION=27.3.4.13 +ARG DEBIAN_VERSION=trixie-20260623-slim + +ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}" + +FROM docker.io/node:22.21.1-bookworm-slim AS node_deps + +WORKDIR /assets +COPY assets/package.json assets/package-lock.json ./ +RUN npm ci + +FROM ${BUILDER_IMAGE} AS builder + +# install build dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential git \ + && rm -rf /var/lib/apt/lists/* + +# prepare build dir +WORKDIR /app + +# install hex + rebar +RUN mix local.hex --force \ + && mix local.rebar --force + +# set build ENV +ENV MIX_ENV="prod" + +# install mix dependencies +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +# copy compile-time config files before we compile dependencies +# to ensure any relevant config change will trigger the dependencies +# to be re-compiled. +COPY config/config.exs config/${MIX_ENV}.exs config/ +RUN mix deps.compile + +RUN mix assets.setup + +COPY priv priv + +COPY lib lib + +# Compile the release +RUN mix compile + +COPY assets assets +COPY --from=node_deps /assets/node_modules assets/node_modules + +# compile assets +RUN mix assets.deploy + +# Changes to config/runtime.exs don't require recompiling the code +COPY config/runtime.exs config/ + +COPY rel rel +RUN mix release + +# start a new build stage so that the final image will only contain +# the compiled release and other runtime necessities +FROM ${RUNNER_IMAGE} AS final + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# Set the locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \ + && locale-gen + +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +WORKDIR "/app" +RUN chown nobody /app + +# set runner ENV +ENV MIX_ENV="prod" + +# Only copy the final release from the build stage +COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/who_need_help ./ + +USER nobody + +# If using an environment that doesn't automatically reap zombie processes, it is +# advised to add an init process such as tini via `apt-get install` +# above and adding an entrypoint. See https://github.com/krallin/tini for details +# ENTRYPOINT ["/tini", "--"] + +CMD ["/app/bin/server"] + +FROM ${BUILDER_IMAGE} AS test + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +RUN mix local.hex --force \ + && mix local.rebar --force + +ENV MIX_ENV="test" + +COPY mix.exs mix.lock ./ +RUN mix deps.get --only test + +COPY config config +RUN mix deps.compile + +COPY .formatter.exs ./ +COPY priv priv +COPY lib lib +COPY test test + +RUN mix compile + +CMD ["mix", "test"] + +# Keep the production release as the default build result while exposing the +# dedicated `test` target to scripts/test.sh. +FROM final AS release diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bc70f3 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# Who Need Help + +Who Need Help is a working mutual-aid MVP for urgent, local, voluntary help. +The first supported scenario is pickup and delivery of a legal medicine that +has already been purchased or reserved. + +It is not an emergency or medical service, does not prescribe or sell medicine, +and does not process payments. A helper may publish an optional external +thank-you link; money goes directly between users outside the platform. + +## What is implemented + +- Phoenix 1.8 LiveView application with email magic-link/password auth and 18+ + self-attestation. +- Data-driven, translated category tree with validated per-category fields, + community proposals/votes, and human approve/reject/merge tools. +- Request lifecycle: `open → matched → in_progress → completed`, plus cancel + and expiry paths. +- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix + PubSub/Presence, and optional foreground-only location sharing. +- Handover code plus both-party confirmation before verified completion. +- Double-blind reviews, public trust summaries, and a helper leaderboard that + prioritizes unique location-supported and handover-verified counterparts + before raw totals. +- Bidirectional discovery blocks, scoped reports, account/request/category + moderation, abuse-signal review, and audited moderator access to only the + conversation linked by a report. +- Optional GPS evidence derived from browser accuracy envelopes. Raw current + positions are deleted on stop, terminal match state, or participant block. +- PostgreSQL-backed cross-replica action-limit policies configured by the + operator. No unapproved numeric thresholds are enabled by default. +- Public manual social links, always marked unverified in this MVP. +- EN/UK/RU UI foundation and installable PWA metadata/service worker. +- Native Android WebView client with the same authenticated LiveView, map, + private chat, and foreground geolocation flows. The reproducible Docker + target currently exports a debug APK; production signing and store + publication are not configured. +- Local, advisory Codex category review through the user's ChatGPT-authenticated + Codex CLI. It receives a PII-free export and never writes to the database. +- One immutable release image with `web`, `worker`, and one-shot `migrate` + roles. +- Docker Compose and Helm/kind deployment paths with 2 web and 2 worker + replicas by default. + +OAuth social verification, public coffee/cinema/hiking activities, background +tracking, platform payments, production Android signing/store publication, +iOS, automatic punitive fraud decisions, and jurisdiction-specific +public-launch policies are deliberately not claimed as complete. + +## Fast start with Docker Compose + +Prerequisite: Docker with the Compose plugin. + +```bash +./scripts/compose-up.sh +``` + +Open: + +- app: +- local email inbox: + +Compose starts Traefik, PostGIS, Mailpit, a migration runner, 2 web replicas, +and 2 Oban worker replicas. It waits for readiness and verifies a PubSub message +broadcast from a different BEAM node. Registration emails appear in Mailpit. + +Inspect the exact state: + +```bash +docker compose -p who_need_help ps -a +docker compose -p who_need_help logs -f web worker +./scripts/verify-realtime-cluster.sh compose +``` + +Local defaults are intentionally limited to local development. Copy +`.env.example` to `.env` and replace every secret before any public deployment. +Generate independent values with `mix phx.gen.secret`; do not reuse the session +secret as the BEAM cookie or handover secret. External Helm deployments must +set `app.host`, `app.scheme`, and `app.urlPort` to the public URL used in email +links, and must set `app.mapTileUrl` to a tile service whose policy and capacity +fit the deployment. + +## Tests + +The reproducible test command builds a dedicated test target and uses the +project's PostGIS service: + +```bash +./scripts/test.sh +``` + +It creates/updates only the project-scoped `who_need_help_test` database. + +## First administrator + +Register and confirm the first account, then explicitly bootstrap it: + +```bash +./scripts/bootstrap-admin.sh you@example.com --confirm +``` + +This succeeds only while no administrator exists and writes an audit event. +After bootstrap, an administrator can manage roles in `/moderation`; the last +administrator cannot demote themselves. For kind, append `kind`: + +```bash +./scripts/bootstrap-admin.sh you@example.com --confirm kind +``` + +## Optional shared action limits + +`RATE_LIMIT_POLICIES_JSON` configures atomic PostgreSQL counters shared by every +web replica. Its shape is: + +```json +{"action_name":{"limit":"POSITIVE_INTEGER","window_seconds":"POSITIVE_INTEGER"}} +``` + +The strings above describe the required types and are not a runnable policy. +Keep `{}` until numeric limits have been approved from policy and observed +traffic. Supported actions are listed in `docs/trust-safety.md`. + +## Local Kubernetes verification + +The kind script downloads checksum-verified kubectl, Helm, and kind binaries +into `.tools/`, creates a project-owned cluster, loads the local image, and +installs the Helm chart, waits for both Deployments, and verifies cross-node +PubSub: + +```bash +./scripts/kind-up.sh +``` + +Open: + +- app: +- Mailpit: + +The script refuses to modify a pre-existing cluster named `who-need-help` +unless the project ownership marker exists. + +For an external cluster, provide a real PostgreSQL/PostGIS service and a +pre-created Secret through `existingSecret`. The chart intentionally has no +invented CPU/RAM limits or HPA thresholds; measure this application in the +target environment before setting them. + +## Local Codex category review + +Export the redacted proposals from a running release: + +```bash +docker compose -p who_need_help exec -T web \ + /app/bin/who_need_help eval \ + 'WhoNeedHelp.CatalogModeration.export_open_proposals() |> IO.puts()' \ + > proposals.json +``` + +Then run the advisory review: + +```bash +./scripts/codex-review-categories.sh proposals.json recommendations.json +``` + +The script exits unless `codex login status` is exactly +`Logged in using ChatGPT`. It uses no OpenAI API key, no usage-based API billing, +and no fallback provider. Recommendations require a human moderator action. + +## Design documentation + +- [Product specification](docs/product-spec.md) +- [Architecture](docs/architecture.md) +- [Trust and safety](docs/trust-safety.md) +- [Implementation verification and known limits](docs/verification.md) +- [PostgreSQL/PostGIS ADR](docs/decisions/0001-postgresql-postgis-over-spacetimedb.md) + +Exact dependency versions are locked in `mix.lock`, +`assets/package-lock.json`, the Dockerfile, Compose file, and tool bootstrap +script. diff --git a/android/.dockerignore b/android/.dockerignore new file mode 100644 index 0000000..dfb3b28 --- /dev/null +++ b/android/.dockerignore @@ -0,0 +1,6 @@ +.gradle +app/build +build +dist +dist-* +local.properties diff --git a/android/Dockerfile b/android/Dockerfile new file mode 100644 index 0000000..f99011a --- /dev/null +++ b/android/Dockerfile @@ -0,0 +1,109 @@ +FROM gradle:9.6.1-jdk17@sha256:7364ce528f33bb6038672bcef990d524f1ad8fbc292935819c235db886d0fae7 AS android-base + +USER root + +ARG ANDROID_COMMAND_LINE_TOOLS_VERSION=14742923 +ARG ANDROID_COMMAND_LINE_TOOLS_SHA1=48833c34b761c10cb20bcd16582129395d121b27 + +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH="${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \ + && curl -fsSL \ + "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_COMMAND_LINE_TOOLS_VERSION}_latest.zip" \ + -o /tmp/android-command-line-tools.zip \ + && echo "${ANDROID_COMMAND_LINE_TOOLS_SHA1} /tmp/android-command-line-tools.zip" \ + | sha1sum -c - \ + && unzip -q /tmp/android-command-line-tools.zip -d /tmp/android-command-line-tools \ + && mv /tmp/android-command-line-tools/cmdline-tools "${ANDROID_HOME}/cmdline-tools/latest" \ + && rm -rf /tmp/android-command-line-tools /tmp/android-command-line-tools.zip \ + && yes | sdkmanager --licenses >/dev/null + +RUN sdkmanager \ + "build-tools;37.0.0" \ + "platforms;android-37.0" \ + && chown -R gradle:gradle "${ANDROID_HOME}" + +FROM android-base AS android-sdk + +USER gradle +WORKDIR /workspace/android + +COPY --chown=gradle:gradle . . + +ARG WNH_DEBUG_BASE_URL + +RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ + --mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \ + gradle --no-daemon \ + "-PWNH_DEBUG_BASE_URL=${WNH_DEBUG_BASE_URL}" \ + testDebugUnitTest lintDebug assembleDebug + +FROM android-base AS emulator + +USER root + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libdbus-1-3 \ + libfontconfig1 \ + libgl1 \ + libnss3 \ + libpulse0 \ + libx11-6 \ + libxcomposite1 \ + libxcursor1 \ + libxi6 \ + libxrandr2 \ + libxrender1 \ + libxtst6 \ + && rm -rf /var/lib/apt/lists/* + +RUN sdkmanager \ + "emulator" \ + "system-images;android-37.0;google_apis_ps16k;x86_64" \ + && chown -R gradle:gradle "${ANDROID_HOME}" + +# The container is operated from the host through `docker exec ... adb`. +# Keep platform-tools in the emulator target without invalidating the large +# system-image layer when application sources change. +RUN sdkmanager "platform-tools" \ + && chown -R gradle:gradle "${ANDROID_HOME}" + +USER gradle + +RUN echo no | avdmanager create avd \ + --force \ + --name who_need_help_api37 \ + --package "system-images;android-37.0;google_apis_ps16k;x86_64" + +COPY --from=android-sdk --chown=gradle:gradle \ + /workspace/android/app/build/outputs/apk/debug/app-debug.apk \ + /opt/who-need-help/who-need-help-debug.apk + +CMD ["/opt/android-sdk/emulator/emulator", \ + "-avd", "who_need_help_api37", \ + "-no-window", \ + "-no-audio", \ + "-no-boot-anim", \ + "-no-snapshot", \ + "-gpu", "software", \ + "-feature", "-Vulkan", \ + "-camera-back", "none", \ + "-camera-front", "none", \ + "-netdelay", "none", \ + "-netspeed", "full"] + +FROM scratch AS artifact + +COPY --from=android-sdk \ + /workspace/android/app/build/outputs/apk/debug/app-debug.apk \ + /who-need-help-debug.apk +COPY --from=android-sdk \ + /workspace/android/app/build/reports/lint-results-debug.html \ + /lint-results-debug.html diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..add7e40 --- /dev/null +++ b/android/README.md @@ -0,0 +1,70 @@ +# Who Need Help for Android + +This module is a native Android WebView shell for the Phoenix application. It +keeps authentication cookies, LiveView WebSockets, MapLibre, chat, and +foreground geolocation in the same trusted origin. + +## Verified build configuration + +- Android Gradle Plugin 9.3.0 +- Gradle 9.6.1 +- compileSdk / targetSdk 37 +- Build Tools 37.0.0 +- Java source and bytecode level 17 +- minSdk 24 (project baseline, not an Android SDK requirement) + +The debug origin is not stored in the Dockerfile or Gradle project. Set +`WNH_DEBUG_BASE_URL` in the repository's ignored `.env` file. The supplied +local configuration uses loopback together with `adb reverse`; debug builds +allow cleartext traffic, while the WebView still restricts in-app navigation to +that one configured origin. + +Release builds do not have a default server. Supply the real HTTPS deployment: + +```sh +./gradlew :app:assembleRelease \ + -PWNH_BASE_URL=https://help.your-domain.example +``` + +The build rejects a missing, HTTP, credentialed, query-bearing, or +fragment-bearing release URL. + +## Reproducible Docker build + +From the repository root: + +```sh +./scripts/android-build.sh +sha256sum android/dist/who-need-help-debug.apk +``` + +The Docker build runs JVM unit tests, Android lint, and `assembleDebug` before it +exports the APK and lint report. + +## Emulator verification + +The optional `emulator` target contains the API 37.0 Google APIs x86_64 system +image. It requires KVM and host networking. Pass the same `.env` value as a +build argument, then expose the Compose proxy to Android with `adb reverse`: + +```sh +set -a +. ./.env +set +a +docker build \ + --build-arg "WNH_DEBUG_BASE_URL=$WNH_DEBUG_BASE_URL" \ + --target emulator \ + -t who-need-help-android:emulator \ + android +docker run --rm --name who-need-help-android-emulator \ + --device /dev/kvm \ + --network host \ + who-need-help-android:emulator +docker exec who-need-help-android-emulator adb reverse tcp:4010 tcp:4010 +docker exec who-need-help-android-emulator adb install \ + /opt/who-need-help/who-need-help-debug.apk +``` + +The application ID `org.whoneedhelp.mobile` is provisional until the publishing +identity and store listing are chosen. Changing it after publication creates a +different Android application. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..7a031b1 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,115 @@ +import java.net.URI +import org.gradle.api.tasks.compile.JavaCompile + +plugins { + id("com.android.application") +} + +val releaseBaseUrl = providers.gradleProperty("WNH_BASE_URL").orElse("") +val debugBaseUrl = providers.gradleProperty("WNH_DEBUG_BASE_URL").orElse("") + +android { + namespace = "org.whoneedhelp.mobile" + compileSdk = 37 + buildToolsVersion = "37.0.0" + + defaultConfig { + applicationId = "org.whoneedhelp.mobile" + minSdk = 24 + targetSdk = 37 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + buildConfigField( + "String", + "BASE_URL", + "\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" + ) + manifestPlaceholders["usesCleartextTraffic"] = "true" + } + + release { + isMinifyEnabled = true + isShrinkResources = true + buildConfigField( + "String", + "BASE_URL", + "\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" + ) + manifestPlaceholders["usesCleartextTraffic"] = "false" + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests.isIncludeAndroidResources = false + } +} + +tasks.matching { it.name == "preReleaseBuild" }.configureEach { + doFirst { + val value = releaseBaseUrl.orNull.orEmpty() + val uri = runCatching { URI(value) }.getOrNull() + + if ( + uri == null || + uri.scheme != "https" || + uri.host.isNullOrBlank() || + uri.userInfo != null || + uri.query != null || + uri.fragment != null + ) { + throw GradleException( + "Release builds require -PWNH_BASE_URL=https://your-real-deployment.example" + ) + } + } +} + +tasks.matching { it.name == "preDebugBuild" }.configureEach { + doFirst { + val value = debugBaseUrl.orNull.orEmpty() + val uri = runCatching { URI(value) }.getOrNull() + + if ( + uri == null || + (uri.scheme != "http" && uri.scheme != "https") || + uri.host.isNullOrBlank() || + uri.userInfo != null || + uri.query != null || + uri.fragment != null + ) { + throw GradleException( + "Debug builds require -PWNH_DEBUG_BASE_URL=http(s)://your-development-host" + ) + } + } +} + +tasks.withType().configureEach { + options.compilerArgs.add("-Xlint:deprecation") +} + +dependencies { + implementation("androidx.activity:activity:1.13.0") + testImplementation("junit:junit:4.13.2") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..eb059fe --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# The app uses only Android framework APIs. Keep rules are intentionally empty. diff --git a/android/app/src/debug/res/xml/network_security_config.xml b/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..f7a98a3 --- /dev/null +++ b/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f259808 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java new file mode 100644 index 0000000..4e88ab0 --- /dev/null +++ b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java @@ -0,0 +1,330 @@ +package org.whoneedhelp.mobile; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.net.Uri; +import android.net.http.SslError; +import android.os.Bundle; +import android.util.Log; +import android.view.ViewGroup; +import android.webkit.CookieManager; +import android.webkit.GeolocationPermissions; +import android.webkit.SslErrorHandler; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.Toast; + +import androidx.activity.ComponentActivity; +import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; + +public final class MainActivity extends ComponentActivity { + private static final String LOG_TAG = "WhoNeedHelpWebView"; + private WebView webView; + private TrustedOrigin trustedOrigin; + private GeolocationPermissions.Callback pendingLocationCallback; + private String pendingLocationOrigin; + private ActivityResultLauncher locationPermissionLauncher; + + @Override + @SuppressLint("SetJavaScriptEnabled") + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + trustedOrigin = TrustedOrigin.parse(BuildConfig.BASE_URL, BuildConfig.DEBUG); + locationPermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), + result -> { + boolean granted = + Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION)) + || Boolean.TRUE.equals( + result.get(Manifest.permission.ACCESS_COARSE_LOCATION) + ); + completeLocationPermission(granted); + } + ); + webView = new WebView(this); + webView.setLayoutParams( + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ); + setContentView(webView); + + WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG); + + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setGeolocationEnabled(true); + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + settings.setMediaPlaybackRequiresUserGesture(true); + settings.setUserAgentString(settings.getUserAgentString() + " WhoNeedHelpAndroid/0.1"); + + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.setAcceptCookie(true); + cookieManager.setAcceptThirdPartyCookies(webView, false); + + webView.setWebViewClient(new TrustedWebViewClient()); + webView.setWebChromeClient(new LocationWebChromeClient()); + getOnBackPressedDispatcher().addCallback( + this, + new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (webView.canGoBack()) { + webView.goBack(); + return; + } + + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + } + } + ); + + if (savedInstanceState == null || webView.restoreState(savedInstanceState) == null) { + webView.loadUrl(initialUrl()); + } + } + + private String initialUrl() { + if (BuildConfig.DEBUG) { + String candidate = getIntent().getStringExtra("initial_url"); + + if (candidate != null && trustedOrigin.matches(candidate)) { + return candidate; + } + } + + return trustedOrigin.startUrl(); + } + + private static String safeLogPath(Uri uri) { + String path = uri.getPath(); + + if (path != null && path.startsWith("/users/log-in/")) { + return "/users/log-in/[redacted]"; + } + + return path; + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + webView.saveState(outState); + super.onSaveInstanceState(outState); + } + + @Override + protected void onPause() { + webView.onPause(); + CookieManager.getInstance().flush(); + super.onPause(); + } + + @Override + protected void onResume() { + super.onResume(); + webView.onResume(); + } + + @Override + protected void onDestroy() { + denyPendingLocation(); + + if (webView != null) { + webView.stopLoading(); + webView.setWebChromeClient(null); + webView.setWebViewClient(null); + webView.destroy(); + } + + super.onDestroy(); + } + + private void requestLocationFor( + String origin, + GeolocationPermissions.Callback callback + ) { + if (!trustedOrigin.matchesOrigin(origin)) { + callback.invoke(origin, false, false); + return; + } + + denyPendingLocation(); + pendingLocationOrigin = origin; + pendingLocationCallback = callback; + + boolean granted = + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + + if (granted) { + completeLocationPermission(true); + return; + } + + locationPermissionLauncher.launch( + new String[] { + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + } + ); + } + + private void completeLocationPermission(boolean granted) { + if (pendingLocationCallback != null && pendingLocationOrigin != null) { + pendingLocationCallback.invoke(pendingLocationOrigin, granted, false); + } + + pendingLocationCallback = null; + pendingLocationOrigin = null; + } + + private void denyPendingLocation() { + completeLocationPermission(false); + } + + private void openExternal(Uri uri) { + String scheme = uri.getScheme(); + + if ( + scheme == null || + !( + scheme.equalsIgnoreCase("http") + || scheme.equalsIgnoreCase("https") + || scheme.equalsIgnoreCase("mailto") + || scheme.equalsIgnoreCase("tel") + ) + ) { + Toast.makeText(this, R.string.unsupported_link, Toast.LENGTH_SHORT).show(); + return; + } + + try { + startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } catch (ActivityNotFoundException exception) { + Toast.makeText(this, R.string.no_link_handler, Toast.LENGTH_SHORT).show(); + } + } + + private final class TrustedWebViewClient extends WebViewClient { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + Uri uri = request.getUrl(); + + if (trustedOrigin.matches(uri.toString())) { + return false; + } + + openExternal(uri); + return true; + } + + @Override + public void onReceivedSslError( + WebView view, + SslErrorHandler handler, + SslError error + ) { + handler.cancel(); + Toast.makeText(MainActivity.this, R.string.secure_connection_failed, Toast.LENGTH_LONG) + .show(); + } + + @Override + public void onReceivedError( + WebView view, + WebResourceRequest request, + WebResourceError error + ) { + if (request.isForMainFrame()) { + if (BuildConfig.DEBUG) { + Log.e( + LOG_TAG, + "Main-frame load failed: code=" + + error.getErrorCode() + + " path=" + + safeLogPath(request.getUrl()) + ); + } + + Toast.makeText(MainActivity.this, R.string.page_load_failed, Toast.LENGTH_LONG) + .show(); + } + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "Main-frame load started: path=" + safeLogPath(Uri.parse(url))); + } + + if (!trustedOrigin.matches(url)) { + view.stopLoading(); + openExternal(Uri.parse(url)); + } + } + + @Override + public void onPageFinished(WebView view, String url) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "Main-frame load finished: path=" + safeLogPath(Uri.parse(url))); + scheduleMapDiagnostics(view, Uri.parse(url)); + } + } + + private void scheduleMapDiagnostics(WebView view, Uri uri) { + String path = uri.getPath(); + + if (path == null || !path.startsWith("/requests/")) { + return; + } + + view.postDelayed( + () -> + view.evaluateJavascript( + "(() => {" + + "const map=document.getElementById('show-map');" + + "if(!map)return 'map=missing';" + + "return 'map=present;canvas='+map.querySelectorAll('canvas').length" + + "+';controls='+map.querySelectorAll('.maplibregl-ctrl').length" + + "+';size='+map.clientWidth+'x'+map.clientHeight;" + + "})()", + result -> Log.d(LOG_TAG, "Map diagnostics: " + result) + ), + 4_000 + ); + } + } + + private final class LocationWebChromeClient extends WebChromeClient { + @Override + public void onGeolocationPermissionsShowPrompt( + String origin, + GeolocationPermissions.Callback callback + ) { + requestLocationFor(origin, callback); + } + + @Override + public void onGeolocationPermissionsHidePrompt() { + denyPendingLocation(); + } + } +} diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/TrustedOrigin.java b/android/app/src/main/java/org/whoneedhelp/mobile/TrustedOrigin.java new file mode 100644 index 0000000..7e72d68 --- /dev/null +++ b/android/app/src/main/java/org/whoneedhelp/mobile/TrustedOrigin.java @@ -0,0 +1,87 @@ +package org.whoneedhelp.mobile; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +final class TrustedOrigin { + private final URI base; + private final int port; + + private TrustedOrigin(URI base) { + this.base = base; + this.port = effectivePort(base); + } + + static TrustedOrigin parse(String value, boolean debugBuild) { + final URI uri; + + try { + uri = new URI(value); + } catch (URISyntaxException exception) { + throw new IllegalArgumentException("BASE_URL is not a valid URI", exception); + } + + String scheme = normalized(uri.getScheme()); + boolean acceptedScheme = "https".equals(scheme) || (debugBuild && "http".equals(scheme)); + + if ( + !acceptedScheme || + uri.getHost() == null || + uri.getHost().isBlank() || + uri.getUserInfo() != null || + uri.getQuery() != null || + uri.getFragment() != null + ) { + throw new IllegalArgumentException("BASE_URL must be an absolute trusted origin"); + } + + return new TrustedOrigin(uri.normalize()); + } + + String startUrl() { + return base.toString(); + } + + boolean matches(String value) { + try { + URI candidate = new URI(value); + + return normalized(base.getScheme()).equals(normalized(candidate.getScheme())) + && normalized(base.getHost()).equals(normalized(candidate.getHost())) + && port == effectivePort(candidate) + && candidate.getUserInfo() == null; + } catch (URISyntaxException exception) { + return false; + } + } + + boolean matchesOrigin(String value) { + try { + URI candidate = new URI(value); + + return matches(value) + && pathIsOrigin(candidate.getPath()) + && candidate.getQuery() == null + && candidate.getFragment() == null; + } catch (URISyntaxException exception) { + return false; + } + } + + private static boolean pathIsOrigin(String path) { + return path == null || path.isEmpty() || "/".equals(path); + } + + private static int effectivePort(URI uri) { + if (uri.getPort() != -1) { + return uri.getPort(); + } + + return "https".equals(normalized(uri.getScheme())) ? 443 : 80; + } + + private static String normalized(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT); + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..2eacf1b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..209f916 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #14532D + #0A2F1A + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8af5ed5 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Who Need Help + No app can open this link. + Could not load Who Need Help. Check your connection and retry. + The secure connection was rejected. + This link type is not supported. + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..8a708e7 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..491c90f --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..6115950 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/test/java/org/whoneedhelp/mobile/TrustedOriginTest.java b/android/app/src/test/java/org/whoneedhelp/mobile/TrustedOriginTest.java new file mode 100644 index 0000000..401b473 --- /dev/null +++ b/android/app/src/test/java/org/whoneedhelp/mobile/TrustedOriginTest.java @@ -0,0 +1,45 @@ +package org.whoneedhelp.mobile; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public final class TrustedOriginTest { + @Test + public void acceptsSameHttpsOriginAndDefaultPort() { + TrustedOrigin origin = TrustedOrigin.parse("https://help.example", false); + + assertTrue(origin.matches("https://help.example/requests/123")); + assertTrue(origin.matches("https://help.example:443/profile")); + assertFalse(origin.matches("https://help.example.evil.test/")); + assertFalse(origin.matches("http://help.example/")); + assertFalse(origin.matches("https://help.example:444/")); + } + + @Test + public void debugBuildCanUseEmulatorLoopback() { + TrustedOrigin origin = TrustedOrigin.parse("http://10.0.2.2:4010", true); + + assertTrue(origin.matches("http://10.0.2.2:4010/requests")); + assertTrue(origin.matchesOrigin("http://10.0.2.2:4010/")); + assertFalse(origin.matchesOrigin("http://10.0.2.2:4010/requests")); + } + + @Test + public void releaseRejectsHttpAndCredentialedOrigins() { + assertThrows( + IllegalArgumentException.class, + () -> TrustedOrigin.parse("http://help.example", false) + ); + assertThrows( + IllegalArgumentException.class, + () -> TrustedOrigin.parse("https://user@help.example", false) + ); + assertThrows( + IllegalArgumentException.class, + () -> TrustedOrigin.parse("https://help.example?redirect=evil", false) + ); + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..cf37549 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "9.3.0" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..3cdd395 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..dbe66e1 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c7b2fa6 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "WhoNeedHelp" +include(":app") diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..1a03c9e --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,122 @@ +/* See the Tailwind configuration guide for advanced usage + https://tailwindcss.com/docs/configuration */ + +@import "tailwindcss" source(none); +@import "../node_modules/maplibre-gl/dist/maplibre-gl.css"; +@import "phoenix-colocated/who_need_help/colocated.css"; +@source "../css"; +@source "../js"; +@source "../../lib/who_need_help_web"; +/* Required for Tailwind to automatically pick up changes in colocated CSS files in dev */ +@source "../../_build/dev/phoenix-colocated/who_need_help/*/"; + +/* A Tailwind plugin that makes "hero-#{ICON}" classes available. + The heroicons installation itself is managed by your mix.exs */ +@plugin "../vendor/heroicons"; + +/* daisyUI Tailwind Plugin. */ +@plugin "daisyui/packages/bundle/daisyui" { + themes: false; +} + +/* daisyUI theme plugin. + We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired + on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */ +@plugin "daisyui/packages/bundle/daisyui-theme" { + name: "dark"; + default: false; + prefersdark: true; + color-scheme: "dark"; + --color-base-100: oklch(30.33% 0.016 252.42); + --color-base-200: oklch(25.26% 0.014 253.1); + --color-base-300: oklch(20.15% 0.012 254.09); + --color-base-content: oklch(97.807% 0.029 256.847); + --color-primary: oklch(58% 0.233 277.117); + --color-primary-content: oklch(96% 0.018 272.314); + --color-secondary: oklch(58% 0.233 277.117); + --color-secondary-content: oklch(96% 0.018 272.314); + --color-accent: oklch(60% 0.25 292.717); + --color-accent-content: oklch(96% 0.016 293.756); + --color-neutral: oklch(37% 0.044 257.287); + --color-neutral-content: oklch(98% 0.003 247.858); + --color-info: oklch(58% 0.158 241.966); + --color-info-content: oklch(97% 0.013 236.62); + --color-success: oklch(60% 0.118 184.704); + --color-success-content: oklch(98% 0.014 180.72); + --color-warning: oklch(66% 0.179 58.318); + --color-warning-content: oklch(98% 0.022 95.277); + --color-error: oklch(58% 0.253 17.585); + --color-error-content: oklch(96% 0.015 12.422); + --radius-selector: 0.25rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.21875rem; + --size-field: 0.21875rem; + --border: 1.5px; + --depth: 1; + --noise: 0; +} + +@plugin "daisyui/packages/bundle/daisyui-theme" { + name: "light"; + default: true; + prefersdark: false; + color-scheme: "light"; + --color-base-100: oklch(98% 0 0); + --color-base-200: oklch(96% 0.001 286.375); + --color-base-300: oklch(92% 0.004 286.32); + --color-base-content: oklch(21% 0.006 285.885); + --color-primary: oklch(70% 0.213 47.604); + --color-primary-content: oklch(98% 0.016 73.684); + --color-secondary: oklch(55% 0.027 264.364); + --color-secondary-content: oklch(98% 0.002 247.839); + --color-accent: oklch(0% 0 0); + --color-accent-content: oklch(100% 0 0); + --color-neutral: oklch(44% 0.017 285.786); + --color-neutral-content: oklch(98% 0 0); + --color-info: oklch(62% 0.214 259.815); + --color-info-content: oklch(97% 0.014 254.604); + --color-success: oklch(70% 0.14 182.503); + --color-success-content: oklch(98% 0.014 180.72); + --color-warning: oklch(66% 0.179 58.318); + --color-warning-content: oklch(98% 0.022 95.277); + --color-error: oklch(58% 0.253 17.585); + --color-error-content: oklch(96% 0.015 12.422); + --radius-selector: 0.25rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.21875rem; + --size-field: 0.21875rem; + --border: 1.5px; + --depth: 1; + --noise: 0; +} + +/* Add variants based on LiveView classes */ +@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &); +@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &); +@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &); + +/* Use the data attribute for dark mode */ +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); + +/* Make LiveView wrapper divs transparent for layout */ +[data-phx-session], [data-phx-teleported-src] { display: contents } + +/* This file is for your main application CSS */ + +.aid-map { + min-height: 22rem; + border-radius: 1.25rem; + overflow: hidden; + border: 1px solid color-mix(in oklab, currentColor 12%, transparent); +} + +.help-card { + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.help-card:hover { + transform: translateY(-2px); + box-shadow: 0 18px 48px rgb(23 37 31 / 0.10); +} diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..11f6de1 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,91 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// +// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file. +// To load it, simply add a second `` to your `root.html.heex` file. + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import {hooks as colocatedHooks} from "phoenix-colocated/who_need_help" +import topbar from "../vendor/topbar" +import {Hooks} from "./hooks" + +const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken}, + hooks: {...colocatedHooks, ...Hooks}, +}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) +window.addEventListener("phx:reset-message-form", ({detail}) => { + const form = document.getElementById(detail.id) + if (form instanceof HTMLFormElement) form.reset() +}) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js")) +} + +// The lines below enable quality of life phoenix_live_reload +// development features: +// +// 1. stream server logs to the browser console +// 2. click on elements to jump to their definitions in your code editor +// +if (process.env.NODE_ENV === "development") { + window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { + // Enable server log streaming to client. + // Disable with reloader.disableServerLogs() + reloader.enableServerLogs() + + // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component + // + // * click with "c" key pressed to open at caller location + // * click with "d" key pressed to open at function component definition location + let keyDown + window.addEventListener("keydown", e => keyDown = e.key) + window.addEventListener("keyup", _e => keyDown = null) + window.addEventListener("click", e => { + if(keyDown === "c"){ + e.preventDefault() + e.stopImmediatePropagation() + reloader.openEditorAtCaller(e.target) + } else if(keyDown === "d"){ + e.preventDefault() + e.stopImmediatePropagation() + reloader.openEditorAtDef(e.target) + } + }, true) + + window.liveReloader = reloader + }) +} diff --git a/assets/js/hooks.js b/assets/js/hooks.js new file mode 100644 index 0000000..92953b3 --- /dev/null +++ b/assets/js/hooks.js @@ -0,0 +1,108 @@ +import maplibregl from "maplibre-gl" + +const defaultStyle = { + version: 8, + sources: { + osm: { + type: "raster", + tiles: [document.documentElement.dataset.mapTileUrl], + tileSize: 256, + attribution: "© OpenStreetMap contributors" + } + }, + layers: [{id: "osm", type: "raster", source: "osm"}] +} + +export const Hooks = { + AidMap: { + mounted() { + this.markers = [] + this.map = new maplibregl.Map({ + container: this.el, + style: defaultStyle, + center: [30.5234, 50.4501], + zoom: 5 + }) + this.map.addControl(new maplibregl.NavigationControl(), "top-right") + this.renderMarkers() + }, + updated() { + this.renderMarkers() + }, + destroyed() { + this.map?.remove() + }, + renderMarkers() { + if (!this.map) return + this.markers.forEach(marker => marker.remove()) + this.markers = [] + + const points = JSON.parse(this.el.dataset.markers || "[]") + points.forEach(point => { + const popup = new maplibregl.Popup({offset: 18}).setHTML( + `${escapeHtml(point.title)}
${escapeHtml(point.location)}` + ) + const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"}) + .setLngLat([point.longitude, point.latitude]) + .setPopup(popup) + .addTo(this.map) + this.markers.push(marker) + }) + + if (points.length === 1) { + this.map.flyTo({center: [points[0].longitude, points[0].latitude], zoom: 12}) + } else if (points.length > 1) { + const bounds = new maplibregl.LngLatBounds() + points.forEach(point => bounds.extend([point.longitude, point.latitude])) + this.map.fitBounds(bounds, {padding: 52, maxZoom: 13}) + } + } + }, + + LocationPicker: { + mounted() { + this.el.addEventListener("click", () => { + navigator.geolocation.getCurrentPosition( + position => { + document.querySelector("#request-latitude").value = position.coords.latitude + document.querySelector("#request-longitude").value = position.coords.longitude + this.el.textContent = "Location added" + this.el.classList.add("btn-success") + }, + () => { + this.el.textContent = "Location permission denied" + this.el.classList.add("btn-error") + }, + {enableHighAccuracy: true, timeout: 10000} + ) + }) + } + }, + + LiveTracking: { + mounted() { + this.destroying = false + this.watchId = navigator.geolocation.watchPosition( + position => this.pushEvent("location-update", { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy_meters: position.coords.accuracy + }), + () => { + if (!this.destroying) this.pushEvent("location-error", {}) + }, + {enableHighAccuracy: true, maximumAge: 5000, timeout: 15000} + ) + }, + destroyed() { + this.destroying = true + if (this.watchId !== undefined) navigator.geolocation.clearWatch(this.watchId) + } + } +} + +function escapeHtml(value) { + const node = document.createElement("div") + node.textContent = value || "" + return node.innerHTML +} diff --git a/assets/package-lock.json b/assets/package-lock.json new file mode 100644 index 0000000..89a3dac --- /dev/null +++ b/assets/package-lock.json @@ -0,0 +1,250 @@ +{ + "name": "who-need-help-assets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "who-need-help-assets", + "dependencies": { + "maplibre-gl": "5.24.0" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + } + } +} diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 0000000..475f4b5 --- /dev/null +++ b/assets/package.json @@ -0,0 +1,7 @@ +{ + "name": "who-need-help-assets", + "private": true, + "dependencies": { + "maplibre-gl": "5.24.0" + } +} diff --git a/assets/tsconfig.json b/assets/tsconfig.json new file mode 100644 index 0000000..a9401b6 --- /dev/null +++ b/assets/tsconfig.json @@ -0,0 +1,32 @@ +// This file is needed on most editors to enable the intelligent autocompletion +// of LiveView's JavaScript API methods. You can safely delete it if you don't need it. +// +// Note: This file assumes a basic esbuild setup without node_modules. +// We include a generic paths alias to deps to mimic how esbuild resolves +// the Phoenix and LiveView JavaScript assets. +// If you have a package.json in your project, you should remove the +// paths configuration and instead add the phoenix dependencies to the +// dependencies section of your package.json: +// +// { +// ... +// "dependencies": { +// ..., +// "phoenix": "../deps/phoenix", +// "phoenix_html": "../deps/phoenix_html", +// "phoenix_live_view": "../deps/phoenix_live_view" +// } +// } +// +// Feel free to adjust this configuration however you need. +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "*": ["../deps/*"] + }, + "allowJs": true, + "noEmit": true + }, + "include": ["js/**/*"] +} diff --git a/assets/vendor/heroicons.js b/assets/vendor/heroicons.js new file mode 100644 index 0000000..296f80e --- /dev/null +++ b/assets/vendor/heroicons.js @@ -0,0 +1,43 @@ +const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") + +module.exports = plugin(function({matchComponents, theme}) { + let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + content = encodeURIComponent(content) + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) +}) diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js new file mode 100644 index 0000000..0552337 --- /dev/null +++ b/assets/vendor/topbar.js @@ -0,0 +1,138 @@ +/** + * @license MIT + * topbar 3.0.0 + * http://buunguyen.github.io/topbar + * Copyright (c) 2024 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + if (!canvas.parentElement) document.body.appendChild(canvas); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..884da77 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,118 @@ +name: who_need_help + +x-app-environment: &app-environment + APP_ROLE: web + DATABASE_URL: ecto://postgres:postgres@db/who_need_help + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-h0rJQH8xgH9vV1cS7gVw6M2oI0Kp3Lx9uA5fE4bD8nR7qT2yW6zC1sP9mN3kF5jH} + HANDOVER_SECRET: ${HANDOVER_SECRET:-local-compose-handover-secret-change-before-public-use} + RELEASE_COOKIE: ${RELEASE_COOKIE:-local-compose-beam-cookie-change-before-public-use} + DNS_CLUSTER_QUERY: web + PHX_HOST: ${PHX_HOST:-localhost} + PHX_SCHEME: http + PHX_URL_PORT: ${HTTP_PORT:-4010} + PORT: "4000" + POOL_SIZE: ${POOL_SIZE:-10} + SMTP_RELAY: mailpit + SMTP_PORT: "1025" + CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured} + RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}} + MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png} + +services: + proxy: + image: traefik:v3.7 + command: + - --api.dashboard=false + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + ports: + - "${HTTP_PORT:-4010}:80" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: [edge, internal] + restart: unless-stopped + + db: + image: postgis/postgis:18-3.6-alpine + environment: + POSTGRES_DB: who_need_help + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d who_need_help"] + interval: 3s + timeout: 3s + retries: 20 + volumes: + - postgres_data:/var/lib/postgresql + networks: [internal] + restart: unless-stopped + + mailpit: + image: axllent/mailpit:v1.27 + ports: + - "${MAILPIT_PORT:-8027}:8025" + networks: [edge, internal] + restart: unless-stopped + + migrate: + image: who-need-help:local + build: + context: . + environment: + <<: *app-environment + APP_ROLE: migrate + command: ["/app/bin/migrate"] + depends_on: + db: + condition: service_healthy + networks: [internal] + restart: "no" + + web: + image: who-need-help:local + environment: + <<: *app-environment + APP_ROLE: web + PHX_SERVER: "true" + depends_on: + migrate: + condition: service_completed_successfully + labels: + - traefik.enable=true + - traefik.docker.network=who_need_help_internal + - traefik.http.routers.who-need-help.rule=PathPrefix(`/`) + - traefik.http.routers.who-need-help.entrypoints=web + - traefik.http.services.who-need-help.loadbalancer.server.port=4000 + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"] + interval: 10s + timeout: 3s + retries: 10 + deploy: + replicas: 2 + networks: [internal] + restart: unless-stopped + + worker: + image: who-need-help:local + environment: + <<: *app-environment + APP_ROLE: worker + command: ["/app/bin/who_need_help", "start"] + depends_on: + migrate: + condition: service_completed_successfully + deploy: + replicas: 2 + networks: [internal] + restart: unless-stopped + +networks: + edge: + internal: + internal: true + +volumes: + postgres_data: diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..4edffb4 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,101 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :who_need_help, :scopes, + user: [ + default: true, + module: WhoNeedHelp.Accounts.Scope, + assign_key: :current_scope, + access_path: [:user, :id], + schema_key: :user_id, + schema_type: :binary_id, + schema_table: :users, + test_data_fixture: WhoNeedHelp.AccountsFixtures, + test_setup_helper: :register_and_log_in_user + ] + +config :who_need_help, + ecto_repos: [WhoNeedHelp.Repo], + generators: [timestamp_type: :utc_datetime, binary_id: true], + app_role: :web, + handover_secret: "development-only-handover-secret", + codex_session_id: "not-configured", + rate_limit_policies: %{}, + map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png" + +config :who_need_help, WhoNeedHelp.Repo, types: WhoNeedHelp.PostgrexTypes + +config :geo_postgis, json_library: Jason + +config :who_need_help, Oban, + repo: WhoNeedHelp.Repo, + queues: [default: 10, maintenance: 2], + plugins: [ + {Oban.Plugins.Pruner, max_age: 86_400}, + {Oban.Plugins.Cron, crontab: [{"* * * * *", WhoNeedHelp.Workers.ExpireRequests}]} + ] + +# Configure the endpoint +config :who_need_help, WhoNeedHelpWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: WhoNeedHelpWeb.ErrorHTML, json: WhoNeedHelpWeb.ErrorJSON], + layout: false + ], + pubsub_server: WhoNeedHelp.PubSub, + live_view: [signing_salt: "IdtA/aHu"] + +# Configure LiveView +config :phoenix_live_view, + # the attribute set on all root tags. Used for Phoenix.LiveView.ColocatedCSS. + root_tag_attribute: "phx-r" + +# Configure the mailer +# +# By default it uses the "Local" adapter which stores the emails +# locally. You can see the emails in your browser, at "/dev/mailbox". +# +# For production it's recommended to configure a different adapter +# at the `config/runtime.exs`. +config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Local + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.25.4", + who_need_help: [ + args: + ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "4.3.0", + who_need_help: [ + args: ~w( + --input=assets/css/app.css + --output=priv/static/assets/css/app.css + ), + cd: Path.expand("..", __DIR__), + env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} + ] + +# Configure Elixir's Logger +config :logger, :default_formatter, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..1cbdf25 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,78 @@ +import Config + +# Configure your database +config :who_need_help, WhoNeedHelp.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "who_need_help_dev", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :who_need_help, WhoNeedHelpWeb.Endpoint, + # Bind to 0.0.0.0 to expose the server to the docker host machine. + # This makes make the service accessible from any network interface. + # Change to `ip: {127, 0, 0, 1}` to allow access only from the server machine. + http: [ip: {0, 0, 0, 0}], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "5RZJQtMxKgyH40kg8va/QVQx81vCSeF9Io7sQGEp9YnSFogb2SzJpMFYffHCVU8Z", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:who_need_help, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:who_need_help, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Enable dev routes for dashboard and mailbox +config :who_need_help, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :default_formatter, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include debug annotations and locations in rendered markup. + # Changing this configuration will require mix clean and a full recompile. + debug_heex_annotations: true, + debug_attributes: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..0c0e59b --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,33 @@ +import Config + +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix assets.deploy` task, +# which you should run after static files are built and +# before starting your production server. +config :who_need_help, WhoNeedHelpWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" + +# Force using SSL in production. This also sets the "strict-security-transport" header, +# known as HSTS. If you have a health check endpoint, you may want to exclude it below. +# Note `:force_ssl` is required to be set at compile-time. +config :who_need_help, WhoNeedHelpWeb.Endpoint, + force_ssl: [ + rewrite_on: [:x_forwarded_proto], + exclude: [ + paths: ["/healthz/live", "/healthz/ready"], + hosts: ["localhost", "127.0.0.1"] + ] + ] + +# Configure Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Req + +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..a33a585 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,187 @@ +import Config + +app_role = + case System.get_env("APP_ROLE", "web") do + "web" -> :web + "worker" -> :worker + "migrate" -> :migrate + other -> raise "APP_ROLE must be web, worker, or migrate; got #{inspect(other)}" + end + +config :who_need_help, + app_role: app_role, + codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"), + map_tile_url: + System.get_env( + "MAP_TILE_URL", + Application.fetch_env!(:who_need_help, :map_tile_url) + ), + rate_limit_policies: + (case System.get_env("RATE_LIMIT_POLICIES_JSON") do + nil -> %{} + "" -> %{} + json -> Jason.decode!(json) + end) + +if dns_query = System.get_env("DNS_CLUSTER_QUERY") do + config :who_need_help, :dns_cluster_query, dns_query +end + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/who_need_help start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :who_need_help, WhoNeedHelpWeb.Endpoint, server: true +end + +config :who_need_help, WhoNeedHelpWeb.Endpoint, + http: [port: String.to_integer(System.get_env("PORT", "4000"))] + +if config_env() == :dev do + # Reload browser tabs when matching files change. + config :who_need_help, WhoNeedHelpWeb.Endpoint, + live_reload: [ + web_console_logger: true, + patterns: [ + # Static assets, except user uploads + ~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$", + # Gettext translations + ~r"priv/gettext/.*\.po$", + # Router, Controllers, LiveViews and LiveComponents + ~r"lib/who_need_help_web/router\.ex$", + ~r"lib/who_need_help_web/(controllers|live|components)/.*\.(ex|heex)$" + ] + ] +end + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + + config :who_need_help, WhoNeedHelp.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + # For machines with several cores, consider starting multiple pools of `pool_size` + # pool_count: 4, + socket_options: maybe_ipv6 + + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + handover_secret = + System.get_env("HANDOVER_SECRET") || + raise """ + environment variable HANDOVER_SECRET is missing. + Generate an independent random value for deterministic handover codes. + """ + + host = System.get_env("PHX_HOST") || "example.com" + scheme = System.get_env("PHX_SCHEME", "https") + + unless scheme in ["http", "https"] do + raise "PHX_SCHEME must be http or https; got #{inspect(scheme)}" + end + + default_url_port = if scheme == "https", do: "443", else: "80" + url_port = String.to_integer(System.get_env("PHX_URL_PORT", default_url_port)) + + config :who_need_help, :handover_secret, handover_secret + + config :who_need_help, WhoNeedHelp.Mailer, + adapter: Swoosh.Adapters.SMTP, + relay: System.get_env("SMTP_RELAY", "mailpit"), + port: String.to_integer(System.get_env("SMTP_PORT", "1025")), + auth: :never, + tls: :never, + ssl: false + + config :who_need_help, WhoNeedHelpWeb.Endpoint, + url: [host: host, port: url_port, scheme: scheme], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://bandit.hexdocs.pm/Bandit.html#t:options/0 + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0} + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :who_need_help, WhoNeedHelpWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://plug.hexdocs.pm/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: + # + # config :who_need_help, WhoNeedHelpWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. + + # ## Configuring the mailer + # + # In production you need to configure the mailer to use a different adapter. + # Here is an example configuration for Mailgun: + # + # config :who_need_help, WhoNeedHelp.Mailer, + # adapter: Swoosh.Adapters.Mailgun, + # api_key: System.get_env("MAILGUN_API_KEY"), + # domain: System.get_env("MAILGUN_DOMAIN") + # + # Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney, + # and Finch out-of-the-box. This configuration is typically done at + # compile-time in your config/prod.exs: + # + # config :swoosh, :api_client, Swoosh.ApiClient.Req + # + # See https://swoosh.hexdocs.pm/Swoosh.html#module-installation for details. +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..e116dcf --- /dev/null +++ b/config/test.exs @@ -0,0 +1,45 @@ +import Config + +# Only in tests, remove the complexity from the password hashing algorithm +config :bcrypt_elixir, :log_rounds, 1 + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +config :who_need_help, WhoNeedHelp.Repo, + username: "postgres", + password: "postgres", + hostname: System.get_env("DB_HOST", "localhost"), + port: String.to_integer(System.get_env("DB_PORT", "5432")), + database: "who_need_help_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: String.to_integer(System.get_env("TEST_POOL_SIZE", "10")) + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :who_need_help, WhoNeedHelpWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "s1PD9eWfZ3XzprmZQ0Wx0CjdKGLDZyXeXzvfqsnNaPS+kUaZ6QRNtq0mPVjh+e9C", + server: false + +# In test we don't send emails +config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Test + +# Disable swoosh api client as it is only required for production adapters +config :swoosh, :api_client, false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true + +# Sort query params output of verified routes for robust url comparisons +config :phoenix, + sort_verified_routes_query_params: true diff --git a/deploy/helm/who-need-help/Chart.yaml b/deploy/helm/who-need-help/Chart.yaml new file mode 100644 index 0000000..9ba353a --- /dev/null +++ b/deploy/helm/who-need-help/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: who-need-help +description: Horizontally scalable Phoenix web and Oban worker deployment +type: application +version: 0.1.0 +appVersion: "0.1.0" +kubeVersion: ">=1.30.0-0" diff --git a/deploy/helm/who-need-help/templates/_helpers.tpl b/deploy/helm/who-need-help/templates/_helpers.tpl new file mode 100644 index 0000000..bce3c89 --- /dev/null +++ b/deploy/helm/who-need-help/templates/_helpers.tpl @@ -0,0 +1,26 @@ +{{- define "who-need-help.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- define "who-need-help.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name (include "who-need-help.name" .) | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{- define "who-need-help.labels" -}} +app.kubernetes.io/name: {{ include "who-need-help.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "who-need-help.selectorLabels" -}} +app.kubernetes.io/name: {{ include "who-need-help.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "who-need-help.secretName" -}} +{{- default (include "who-need-help.fullname" .) .Values.existingSecret }} +{{- end }} diff --git a/deploy/helm/who-need-help/templates/deployments.yaml b/deploy/helm/who-need-help/templates/deployments.yaml new file mode 100644 index 0000000..abe0738 --- /dev/null +++ b/deploy/helm/who-need-help/templates/deployments.yaml @@ -0,0 +1,121 @@ +{{- $root := . -}} +{{- range $component, $settings := dict "web" .Values.web "worker" .Values.worker }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "who-need-help.fullname" $root }}-{{ $component }} + labels: + {{- include "who-need-help.labels" $root | nindent 4 }} + app.kubernetes.io/component: {{ $component }} +spec: + replicas: {{ $settings.replicas }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "who-need-help.selectorLabels" $root | nindent 6 }} + app.kubernetes.io/component: {{ $component }} + template: + metadata: + annotations: + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") $root | sha256sum }} + {{- with $root.Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "who-need-help.selectorLabels" $root | nindent 8 }} + app.kubernetes.io/component: {{ $component }} + {{- with $root.Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + subdomain: {{ include "who-need-help.fullname" $root }}-headless + securityContext: + runAsNonRoot: true + runAsUser: 65534 + fsGroup: 65534 + initContainers: + - name: await-migrations + image: "{{ $root.Values.image.repository }}:{{ $root.Values.image.tag }}" + imagePullPolicy: {{ $root.Values.image.pullPolicy }} + command: ["/app/bin/await_migrations"] + envFrom: + - secretRef: + name: {{ include "who-need-help.secretName" $root }} + env: + - name: APP_ROLE + value: migrate + containers: + - name: {{ $component }} + image: "{{ $root.Values.image.repository }}:{{ $root.Values.image.tag }}" + imagePullPolicy: {{ $root.Values.image.pullPolicy }} + {{- if eq $component "worker" }} + command: ["/app/bin/who_need_help", "start"] + {{- end }} + envFrom: + - secretRef: + name: {{ include "who-need-help.secretName" $root }} + env: + - name: APP_ROLE + value: {{ $component }} + - name: PHX_SERVER + value: {{ if eq $component "web" }}"true"{{ else }}"false"{{ end }} + - name: PHX_HOST + value: {{ $root.Values.app.host | quote }} + - name: PHX_SCHEME + value: {{ $root.Values.app.scheme | quote }} + - name: PHX_URL_PORT + value: {{ $root.Values.app.urlPort | quote }} + - name: PORT + value: {{ $root.Values.app.port | quote }} + - name: POOL_SIZE + value: {{ $root.Values.app.poolSize | quote }} + - name: SMTP_RELAY + value: {{ $root.Values.app.smtpRelay | quote }} + - name: SMTP_PORT + value: {{ $root.Values.app.smtpPort | quote }} + - name: CODEX_SESSION_ID + value: {{ $root.Values.app.codexSessionId | quote }} + - name: RATE_LIMIT_POLICIES_JSON + value: {{ $root.Values.app.rateLimitPoliciesJson | quote }} + - name: MAP_TILE_URL + value: {{ $root.Values.app.mapTileUrl | quote }} + - name: DNS_CLUSTER_QUERY + value: "{{ include "who-need-help.fullname" $root }}-headless.{{ $root.Release.Namespace }}.svc.cluster.local" + {{- if eq $component "web" }} + ports: + - name: http + containerPort: 4000 + readinessProbe: + httpGet: + path: /healthz/ready + port: http + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz/live + port: http + periodSeconds: 10 + {{- end }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + {{- with $root.Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $root.Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $root.Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +--- +{{- end }} diff --git a/deploy/helm/who-need-help/templates/ingress.yaml b/deploy/helm/who-need-help/templates/ingress.yaml new file mode 100644 index 0000000..05bfa58 --- /dev/null +++ b/deploy/helm/who-need-help/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "who-need-help.fullname" . }} + labels: + {{- include "who-need-help.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "who-need-help.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/who-need-help/templates/migrate-job.yaml b/deploy/helm/who-need-help/templates/migrate-job.yaml new file mode 100644 index 0000000..210f131 --- /dev/null +++ b/deploy/helm/who-need-help/templates/migrate-job.yaml @@ -0,0 +1,40 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{ include "who-need-help.fullname" . }}-migrate-{{ .Release.Revision }}" + labels: + {{- include "who-need-help.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "0" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 4 + template: + metadata: + labels: + {{- include "who-need-help.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: migrate + spec: + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 65534 + fsGroup: 65534 + containers: + - name: migrate + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["/app/bin/migrate"] + envFrom: + - secretRef: + name: {{ include "who-need-help.secretName" . }} + env: + - name: APP_ROLE + value: migrate + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] diff --git a/deploy/helm/who-need-help/templates/pdb.yaml b/deploy/helm/who-need-help/templates/pdb.yaml new file mode 100644 index 0000000..c65f9d7 --- /dev/null +++ b/deploy/helm/who-need-help/templates/pdb.yaml @@ -0,0 +1,25 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "who-need-help.fullname" . }}-web + labels: + {{- include "who-need-help.labels" . | nindent 4 }} +spec: + minAvailable: 1 + selector: + matchLabels: + {{- include "who-need-help.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: web +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "who-need-help.fullname" . }}-worker + labels: + {{- include "who-need-help.labels" . | nindent 4 }} +spec: + minAvailable: 1 + selector: + matchLabels: + {{- include "who-need-help.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker diff --git a/deploy/helm/who-need-help/templates/secret.yaml b/deploy/helm/who-need-help/templates/secret.yaml new file mode 100644 index 0000000..f354edf --- /dev/null +++ b/deploy/helm/who-need-help/templates/secret.yaml @@ -0,0 +1,18 @@ +{{- if not .Values.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "who-need-help.fullname" . }} + labels: + {{- include "who-need-help.labels" . | nindent 4 }} + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation +type: Opaque +stringData: + DATABASE_URL: {{ .Values.app.databaseUrl | quote }} + SECRET_KEY_BASE: {{ .Values.app.secretKeyBase | quote }} + HANDOVER_SECRET: {{ .Values.app.handoverSecret | quote }} + RELEASE_COOKIE: {{ .Values.app.releaseCookie | quote }} +{{- end }} diff --git a/deploy/helm/who-need-help/templates/services.yaml b/deploy/helm/who-need-help/templates/services.yaml new file mode 100644 index 0000000..856c1d8 --- /dev/null +++ b/deploy/helm/who-need-help/templates/services.yaml @@ -0,0 +1,34 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "who-need-help.fullname" . }} + labels: + {{- include "who-need-help.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "who-need-help.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: web + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + {{- with .Values.service.nodePort }} + nodePort: {{ . }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "who-need-help.fullname" . }}-headless + labels: + {{- include "who-need-help.labels" . | nindent 4 }} +spec: + clusterIP: None + publishNotReadyAddresses: true + selector: + {{- include "who-need-help.selectorLabels" . | nindent 4 }} + ports: + - name: epmd + port: 4369 + targetPort: 4369 diff --git a/deploy/helm/who-need-help/values-kind.yaml b/deploy/helm/who-need-help/values-kind.yaml new file mode 100644 index 0000000..27182d6 --- /dev/null +++ b/deploy/helm/who-need-help/values-kind.yaml @@ -0,0 +1,18 @@ +image: + repository: who-need-help + tag: local + pullPolicy: IfNotPresent + +service: + type: NodePort + port: 80 + nodePort: 30080 + +app: + host: localhost + scheme: http + urlPort: "4011" + databaseUrl: ecto://postgres:postgres@postgis/who_need_help + secretKeyBase: y9XxDIDxcn8uHarbX1B4feQl0xXi413tdYO472d758DtvslbtlVdUs2HdxPEPXCQ + handoverSecret: L2a4yz6nLYkWvIBTlREj8uDByngsY39JfLIfdYNDH/7dnIQEVW4T8OCjCkHOGZ7H + releaseCookie: OwNDSCX7aQkkNXovILLiESVolaRuYm67xC+JbMC3j3ERTY6sHIM/F4gc0elm1l07 diff --git a/deploy/helm/who-need-help/values.yaml b/deploy/helm/who-need-help/values.yaml new file mode 100644 index 0000000..c0d52dd --- /dev/null +++ b/deploy/helm/who-need-help/values.yaml @@ -0,0 +1,51 @@ +image: + repository: who-need-help + tag: local + pullPolicy: IfNotPresent + +web: + replicas: 2 + +worker: + replicas: 2 + +service: + type: ClusterIP + port: 80 + nodePort: null + +app: + host: localhost + scheme: https + urlPort: "443" + port: "4000" + poolSize: "10" + databaseUrl: ecto://postgres:postgres@postgis/who_need_help + secretKeyBase: replace-before-public-deployment + handoverSecret: replace-before-public-deployment + releaseCookie: replace-before-public-deployment + codexSessionId: not-configured + # Shared limits are opt-in; set only after product policy thresholds are approved. + rateLimitPoliciesJson: "{}" + mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png + smtpRelay: mailpit + smtpPort: "1025" + +existingSecret: "" + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: who-need-help.local + paths: + - path: / + pathType: Prefix + tls: [] + +podAnnotations: {} +podLabels: {} +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/deploy/kind/cluster.yaml b/deploy/kind/cluster.yaml new file mode 100644 index 0000000..7937fa8 --- /dev/null +++ b/deploy/kind/cluster.yaml @@ -0,0 +1,12 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: who-need-help +nodes: + - role: control-plane + extraPortMappings: + - containerPort: 30080 + hostPort: 4011 + protocol: TCP + - containerPort: 30227 + hostPort: 8028 + protocol: TCP diff --git a/deploy/kind/dependencies.yaml b/deploy/kind/dependencies.yaml new file mode 100644 index 0000000..3c95c35 --- /dev/null +++ b/deploy/kind/dependencies.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgis +spec: + replicas: 1 + selector: + matchLabels: {app: postgis} + template: + metadata: + labels: {app: postgis} + spec: + containers: + - name: postgis + image: postgis/postgis:18-3.6-alpine + env: + - {name: POSTGRES_DB, value: who_need_help} + - {name: POSTGRES_USER, value: postgres} + - {name: POSTGRES_PASSWORD, value: postgres} + ports: + - {name: postgres, containerPort: 5432} + readinessProbe: + exec: + command: ["pg_isready", "-U", "postgres", "-d", "who_need_help"] + periodSeconds: 3 + volumeMounts: + - {name: data, mountPath: /var/lib/postgresql} + volumes: + - name: data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: postgis +spec: + selector: {app: postgis} + ports: + - {name: postgres, port: 5432, targetPort: postgres} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mailpit +spec: + replicas: 1 + selector: + matchLabels: {app: mailpit} + template: + metadata: + labels: {app: mailpit} + spec: + containers: + - name: mailpit + image: axllent/mailpit:v1.27 + ports: + - {name: smtp, containerPort: 1025} + - {name: http, containerPort: 8025} +--- +apiVersion: v1 +kind: Service +metadata: + name: mailpit +spec: + type: NodePort + selector: {app: mailpit} + ports: + - {name: smtp, port: 1025, targetPort: smtp} + - {name: http, port: 8025, targetPort: http, nodePort: 30227} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..728e94b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,171 @@ +# Architecture + +Status: target architecture for the first working release. + +## System shape + +Who Need Help is a modular Phoenix application rather than a collection of +premature microservices. It produces one immutable image with three runtime +roles: + +- `web`: Phoenix Endpoint, LiveView, PubSub, and Presence. +- `worker`: Oban queues and scheduled jobs; no public HTTP listener. +- `migrate`: a one-shot database migration command before rollout. + +PostgreSQL with PostGIS is the system of record. User-visible writes are +committed to PostgreSQL before a PubSub notification is broadcast. This makes +realtime delivery recoverable: after a reconnect, the client reads authoritative +state from the database. + +```text +browser/PWA or Android WebView + │ + ▼ +Traefik / Kubernetes Service + │ + ├── web replica A ─┐ + └── web replica B ─┼── PostgreSQL + PostGIS + │ + worker A/B ─────┤ + └── Oban jobs + +web and worker nodes form a BEAM cluster for distributed PubSub/Presence +``` + +The Android module is a thin same-origin WebView shell rather than a separate +API client. Its debug origin is supplied at build time from the repository's +ignored `.env`; release builds require an explicit HTTPS origin. Authentication +cookies, LiveView WebSockets, MapLibre, private chat, and foreground +geolocation therefore use the same Phoenix application paths and authorization +rules as the browser. Production signing and distribution are separate +operational work and are not represented as complete. + +## Application boundaries + +- `Accounts`: users, authentication, social identities, privacy preferences, + blocks, and roles. +- `Catalog`: category tree, proposals, votes, moderation decisions, and + translated labels. +- `Help`: requests, assignments, state transitions, handover codes, and + completion evidence. +- `Messaging`: match chat and delivery receipts. +- `Tracking`: foreground sharing sessions, current positions, and derived + proximity signals. +- `Trust`: reviews, reports, blocks, leaderboard/reputation projections, + abuse signals, moderator audit events, and shared rate-limit policies. + +Contexts normally call each other through public functions. A small number of +documented trust-and-safety transactions update related schemas together when +atomic cleanup or moderation requires it; those operations stay in context +functions rather than controllers or LiveViews. + +## Realtime and clustering + +Phoenix PubSub and Presence handle transient fan-out. Phoenix's generated +`dns_cluster` dependency discovers nodes using DNS polling: + +- Compose nodes discover the `web` service on the shared internal network; + workers join through those nodes and distributed Erlang forms the full mesh. +- Kubernetes uses a headless service. + +No sticky session is required for authenticated requests. Session cookies are +signed by a shared secret. Uploads, if later introduced, must go to shared +object storage rather than a container filesystem. + +Oban is enabled only for the worker role. PostgreSQL coordinates queues and +leadership, so no Redis dependency is introduced. + +## Geospatial data + +Locations are stored as WGS84 coordinates through the PostGIS Ecto extension. +Current movement and proximity evidence is derived in the application from two +current points and their browser-reported accuracy envelopes; no route is +retained. MapLibre GL JS renders the map through a small JavaScript hook. The +tile/style URL and attribution are configuration, not hard-coded provider +assumptions. Production operators must use a tile service whose policy and +capacity fit the traffic; the public OpenStreetMap tile service is best-effort +and has a usage policy, not an application backend. + +## Data durability and retention + +- Requests, state transitions, messages, reviews, reports, and audit events are + durable database records. +- A tracking session stores only its current position while active, never a + route history. +- Stopping sharing, completing/cancelling a match, or blocking the counterpart + deletes exact position records. Derived sample count, uncertainty-adjusted + distance, movement timestamp, and proximity timestamp remain. +- Secrets, exact coordinates, and private chat are excluded from local Codex + moderation inputs. + +## Deployment + +### Docker Compose + +Normal development starts: + +- Traefik +- 2 × web +- 2 × worker +- 1 × PostGIS +- 1 × Mailpit + +Compose services have no fixed container names, enabling replicas. The project +uses an isolated Compose project name and network. PostgreSQL is intentionally +single-instance in the local profile; multiple uncoordinated containers would +not create database high availability. + +### Kubernetes + +The Helm chart contains separate web and worker Deployments, Services, a +headless cluster-discovery Service, a migration Job, Secret interfaces, probes, +and disruption-aware rolling updates. It does not invent +CPU/memory limits or an HPA threshold before measurements exist. + +The bundled kind path is for reproducible local verification. A production +database should be an independently operated PostgreSQL/PostGIS service with +backups and a tested recovery procedure. + +## Shared abuse counters + +Action buckets live in PostgreSQL and use an atomic upsert keyed by action, +hashed scope, and aligned time window. This works across all web replicas +without an in-memory or Redis singleton. Policies are supplied through +`RATE_LIMIT_POLICIES_JSON`; no numeric product policy is compiled into the +application. Expired buckets are pruned by the maintenance worker. + +The Compose and kind scripts finish by subscribing on one live BEAM node, +broadcasting through a different connected node, and failing if the PubSub +probe is not received. + +## AI boundary + +AI is not in the critical request or safety path. An administrator can export a +redacted batch of category proposals to the locally installed Codex CLI: + +```text +codex exec --ephemeral --sandbox read-only --output-schema … +``` + +The process uses the user's ChatGPT-authenticated local Codex session. It never +uses an OpenAI API key, usage-based API billing, or an API fallback. The model +may group duplicates and draft English, Ukrainian, and Russian labels; a human +moderator applies every change. + +## Versioning policy + +Exact image and package versions are pinned in the repository after successful +build verification. Renovation is a deliberate change accompanied by tests, +not an implicit `latest` pull. + +## Primary references + +- [Phoenix 1.8 documentation](https://hexdocs.pm/phoenix/overview.html) +- [Phoenix PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html) +- [Phoenix Presence](https://hexdocs.pm/phoenix/presence.html) +- [Oban](https://hexdocs.pm/oban/Oban.html) +- [DNSCluster](https://hexdocs.pm/dns_cluster/DNSCluster.html) +- [PostGIS](https://postgis.net/documentation/) +- [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/) +- [OpenStreetMap tile usage policy](https://operations.osmfoundation.org/policies/tiles/) +- [W3C Geolocation](https://www.w3.org/TR/geolocation/) diff --git a/docs/category-moderation-output.schema.json b/docs/category-moderation-output.schema.json new file mode 100644 index 0000000..938876b --- /dev/null +++ b/docs/category-moderation-output.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["recommendations"], + "properties": { + "recommendations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["proposal_id", "decision", "rationale", "suggested_slug", "merge_target_slug"], + "properties": { + "proposal_id": {"type": "string"}, + "decision": { + "type": "string", + "enum": ["approve", "reject", "merge", "manual_review"] + }, + "rationale": {"type": "string"}, + "suggested_slug": {"type": ["string", "null"]}, + "merge_target_slug": {"type": ["string", "null"]} + } + } + } + } +} diff --git a/docs/decisions/0001-postgresql-postgis-over-spacetimedb.md b/docs/decisions/0001-postgresql-postgis-over-spacetimedb.md new file mode 100644 index 0000000..939fc29 --- /dev/null +++ b/docs/decisions/0001-postgresql-postgis-over-spacetimedb.md @@ -0,0 +1,54 @@ +# ADR 0001: PostgreSQL/PostGIS instead of SpacetimeDB + +- Status: accepted +- Date: 2026-07-18 + +## Context + +The system needs durable relational transactions, geospatial proximity queries, +authentication data, chat history, trust/audit records, background jobs, Ecto +integration, and horizontal Phoenix/Oban nodes. + +SpacetimeDB was considered because its server-authoritative realtime model is +interesting for live applications. + +## Decision + +Use PostgreSQL with PostGIS as the system of record, Ecto for persistence, +Oban for durable jobs, and Phoenix PubSub/Presence for transient realtime +fan-out. + +## Evidence + +At the time of this decision, SpacetimeDB's official language support does not +list an Elixir server module or client SDK. Its PGWire documentation describes +Simple Query support and limitations around parameterized queries, user +transactions, catalogs, and roles. Those constraints do not match a conventional +Ecto/Oban application. Its self-hosted deployment documentation describes a +standalone process, while replicated operation is a different deployment +offering. + +These are integration constraints, not a general judgment that SpacetimeDB is +inferior. + +## Consequences + +- The application uses well-supported Ecto and PostGIS queries. +- Database-backed Oban coordinates multiple worker replicas. +- Phoenix clients reload durable state after PubSub loss or reconnect. +- A single local PostgreSQL container is not described as highly available. +- Production database HA, backups, and recovery are an operational concern + separate from web/worker horizontal scaling. +- SpacetimeDB can be reconsidered for a bounded realtime subsystem if official + Elixir/Ecto-compatible integration and deployment needs change. + +## References + +- [SpacetimeDB FAQ](https://spacetimedb.com/docs/intro/faq/) +- [Language support](https://spacetimedb.com/docs/intro/language-support/) +- [PostgreSQL wire protocol](https://spacetimedb.com/docs/how-to/pg-wire/) +- [Commit log internals](https://spacetimedb.com/docs/reference/internals/commitlog/) +- [Self-hosting](https://spacetimedb.com/docs/how-to/deploy/self-hosting/) +- [SpacetimeDB source and license](https://github.com/clockworklabs/SpacetimeDB) +- [Ecto](https://hexdocs.pm/ecto/Ecto.html) +- [PostGIS](https://postgis.net/documentation/) diff --git a/docs/product-spec.md b/docs/product-spec.md new file mode 100644 index 0000000..aab590c --- /dev/null +++ b/docs/product-spec.md @@ -0,0 +1,118 @@ +# Who Need Help — product specification + +Status: MVP specification for OpenAI Build Week, 2026-07-18. + +## Purpose + +Who Need Help connects an adult who needs urgent, practical nearby help with an +adult volunteer who can provide it without a mandatory fee. + +The hackathon MVP is deliberately narrow: a requester can ask a nearby person +to pick up and deliver medication that has already been legally purchased or +reserved. The product does not diagnose, prescribe, recommend, sell, reimburse, +or handle controlled substances. + +## Primary user journey + +1. A user creates an account and self-attests that they are at least 18. +2. The user creates an urgent request with a title, instructions, approximate + public location, expiry time, and medicine-pickup category. +3. Nearby helpers see the request on a list and map. +4. One helper accepts it; requester and helper receive a private matched chat. +5. Either party may share foreground location for the active match. +6. The requester gives the helper a one-time handover code after receiving help. +7. Both sides confirm completion and may leave a double-blind review. +8. The requester may send a voluntary thank-you through a helper-provided + external link. The platform does not collect or route money. + +## Request lifecycle + +`open → matched → in_progress → completed` + +Terminal alternatives are `cancelled` and `expired`. The MVP permits one active +helper per request. Every state transition is authorized and recorded. + +## MVP scope + +- Email magic-link/password authentication. +- English default interface plus Ukrainian and Russian translations. +- Medicine pickup/delivery requests. +- Hierarchical, data-driven categories with validated structured request fields. +- Request list, filters, details, and a MapLibre map. +- Matching, presence, chat, and foreground location updates. +- Configurable location privacy. +- One-time handover confirmation. +- Double-blind reviews and a reputation summary. +- Category proposals and voting. +- Bidirectional blocks, scoped request/assignment/message reports, and audited + report-evidence access. +- Role-protected report, account, abuse-signal, request, role, and category + moderation. +- A helper leaderboard based primarily on unique location-supported and + handover-verified counterpart counts. +- Configurable PostgreSQL-backed action limits shared by every web replica. +- PWA installation and foreground geolocation. +- Native Android WebView client for the same-origin authenticated map, matched + chat, and foreground geolocation flows; reproducible debug APK build. +- Admin-only local Codex moderation batch for category proposals. + +## Explicitly outside the MVP + +- Medical advice, prescriptions, pharmacy integrations, medication sale, and + controlled-substance delivery. +- Platform payments, escrow, fees, or compulsory compensation. +- Background PWA location tracking. +- Multiple simultaneous helpers on one request. +- Public activities such as coffee, cinema, or hiking. These will be a separate + future `Activity` mode, not an urgent-help category. +- Production-signed Android release, store publication, and native iOS + application. +- OAuth verification and automated social-network identity checks. The MVP + supports manually attached public links and marks them unverified. +- Automatic punitive abuse enforcement and unapproved production thresholds. +- Claims that identity, safety, or fraud prevention is perfect. + +## Extensibility + +Categories form a moderated tree and are not compiled into application code. +After the MVP the same request workflow can support fuel delivery, roadside +help, bicycle punctures, broken motorcycle chains, and urgent household help. +Each category can add validated text, select, and boolean fields through its +stored schema without changing the request form code or core state machine. + +Social links are user-provided references and are visibly marked unverified. +The data model reserves `verified_at` for a future OAuth flow, but the current +MVP does not set it. + +## Privacy settings + +Every user chooses one location visibility level: + +- `hidden` +- `approximate_public` (default) +- `exact_for_active_match` +- `exact_public` (explicit per-request opt-in) + +Public discovery uses either no marker, an approximate marker, or an explicitly +public exact marker. Exact active coordinates are ephemeral and no route +history is stored. The current point is deleted when sharing ends, a match +becomes terminal, or either participant blocks the other. Derived trust signals +may be retained. + +The data model reserves a direct-message policy for a future general messaging +feature. The current product has no unsolicited inbox: chat exists only for +matched participants, and a block prevents new messages. + +## Success criteria for the hackathon + +- A new user can complete the primary journey locally in two browser sessions. +- The debug Android client can authenticate against the local stack and use + the request map, matched chat, and foreground sharing on an emulator. +- The same immutable image runs as both web and worker roles. +- Two web replicas and two worker replicas run concurrently in normal + development. +- A request, chat message, and location update reach clients connected to + different web replicas. +- The repository contains reproducible Compose and Kubernetes deployment paths. +- The feedback page records the main local Codex session identifier for the + Build Week submission. diff --git a/docs/trust-safety.md b/docs/trust-safety.md new file mode 100644 index 0000000..4cc84fc --- /dev/null +++ b/docs/trust-safety.md @@ -0,0 +1,114 @@ +# Trust, safety, and abuse model + +Status: MVP policy and implementation requirements. This document does not +claim that the system can eliminate bad actors. + +## Safety boundary + +Who Need Help is a coordination tool for adults. It is not an emergency service. +Every urgent screen must tell users to contact local emergency services when +life, health, fire, violence, or immediate danger is involved. + +The medicine category is limited to pickup/delivery of an already purchased or +reserved legal item. Requests for medical advice, prescriptions, cash advances, +controlled substances, or suspicious repackaging are reportable and removable. + +## Verified completion + +A completion is counted as verified only when all of these occur: + +1. A helper accepted the request. +2. Both parties confirmed the completion. +3. The requester shared the one-time handover code with the helper. + +Optional location proximity is supporting evidence, never a mandatory +prerequisite. This avoids excluding users who deny geolocation permission, +while making simple remote rating rings less useful. + +The public reputation summary separates: + +- completed requests; +- unique people helped; +- verified handovers. +- optional location-supported interactions. + +The helper leaderboard ranks unique counterparts with optional proximity plus +helper-movement evidence first, then unique handover-verified counterparts, +then unique counterparts, and only then raw completions. Repeated work with the +same person cannot increase the primary unique counters. + +## Reviews + +Reviews are double-blind. A review becomes visible only after both participants +submit. There is no automatic reveal timeout in the MVP. Reviews are allowed +only for completed matched requests. + +## Identity and social links + +Email ownership can be confirmed through a magic link. Manually attached +Instagram, Facebook, Telegram, Google, or other URLs are labelled “unverified.” +OAuth verification is not implemented in the current MVP. + +The MVP uses 18+ self-attestation. It does not imply government identity +verification. + +## Implemented abuse controls + +- Completion requires both confirmations plus a request-specific handover code. +- Public reputation separates completed work, unique people, and handover-code + verification. +- Reviews require a completed assignment and remain hidden until both parties + submit. +- Blocks remove discovery in both directions, prevent acceptance/new chat, and + delete the pair's active exact positions. +- Reports target exactly one request, assignment, or message. A moderator can + load chat only through an assignment/message report. +- Missing optional proximity/movement, repeated pairs, reciprocal direction, + and configured action velocity create review signals. They do not + automatically punish an account. +- PostgreSQL action buckets enforce only operator-supplied policies across all + replicas. Registration and magic-link email scopes can also be configured. +- No public exact location by default. + +No numeric rate policy is enabled by default because no threshold has been +approved or measured for this deployment. Supported action names currently +include `registration_email`, `magic_link_email`, `create_request`, +`accept_request`, `start`, `confirm`, `verify_handover`, `cancel_request`, +`withdraw_assignment`, `send_message`, `start_tracking`, `tracking_position`, +`review`, `report`, `block`, `category_proposal`, and `category_vote`. + +The system does not claim to be bot-proof. Email confirmation, database +uniqueness, unique-counterpart ranking, location evidence, velocity policies, +signals, and human review raise the cost of simple rating farms but cannot prove +identity or eliminate coordinated abuse. + +## Moderator access + +A moderator can access private chat only from a specific assignment or message +report. Every access records the moderator, report, linked assignment, message +count, and timestamp. The report itself contains the reason. Browsing private +conversations without a qualifying report is not a product capability. + +Report, request, signal, category, role, and account moderation are role +protected. The local Codex +batch receives proposal text and aggregate vote counts only. It receives no +private messages, email addresses, OAuth tokens, exact coordinates, or raw +tracking routes. + +## Money + +Help is free. A helper may add an external voluntary tip link. It appears after +completion, is labelled optional, and routes directly to the helper. The +platform does not calculate, collect, split, refund, guarantee, or report the +payment. The legal and tax treatment remains dependent on the users' +jurisdiction and is not represented by the product. + +## Operational response + +Reports have `open`, `reviewing`, `resolved`, and `dismissed` states. A severe +report can hide a request and temporarily restrict an account pending review. +Suspension invalidates that account's login sessions. The first administrator +requires an explicit one-time operational bootstrap, and later role changes are +audited; the last administrator cannot demote themselves. +The operator must publish jurisdiction-specific emergency contacts, privacy +notice, prohibited-items policy, and data-retention policy before public launch. diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..a6b892e --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,89 @@ +# Who Need Help — implementation verification + +Observed on 2026-07-18 in the local workspace. This report separates observed +results from product limits and unknown production properties. + +## Verified MVP capabilities + +| Requirement | Status | Observed evidence | Limit | +| --- | --- | --- | --- | +| Urgent medicine-help flow | Implemented and tested | Request creation, discovery, matching, start, handover, two-party completion, and review rules are covered by the Phoenix test suite and exercised in the local UI. | The product coordinates pickup of an already purchased or reserved legal item; it is not a pharmacy, medical, or emergency service. | +| Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities are intentionally a future `Activity` mode. | +| Map and discovery | Implemented and browser-verified | A headed Chrome session rendered the MapLibre request map, marker, controls, attribution, and configured OpenStreetMap raster tiles. Tile requests returned HTTP 200 during the check. | A production operator must configure a tile provider appropriate for its policy and traffic. | +| Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. | +| Foreground tracking | Implemented and cross-client verified | Android emulator coordinates created an active tracking session and appeared for the matched requester. Stopping sharing made the session inactive and removed raw current positions. | No background tracking or route history is implemented. | +| Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. | +| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. | +| Social profiles | Implemented as manual links | Public links can be attached and are visibly labelled unverified. | OAuth/social-network verification is not implemented. | +| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | +| Android client | Debug client implemented and emulator-verified | The native package `org.whoneedhelp.mobile.debug` launches the same authenticated LiveView app. Login persistence, map, chat, and foreground geolocation were exercised on API 37. The final Docker emulator image separately passed boot/install/launch smoke verification. | Production signing, a public production origin, Play Store publication, background tracking, and iOS are not implemented. | +| Multiple web/worker instances | Implemented and locally verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | + +## Reproducible checks + +- `./scripts/test.sh`: 121 tests, 0 failures in the final run. +- `mix format --check-formatted`: passed in the final run. +- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and + `assembleDebug` passed; the final lint report contains no errors or warnings. +- Release Android guard: a release build without `WNH_BASE_URL` failed as + intended; release configuration accepts only an explicit HTTPS origin. +- Helm lint, template rendering, server-side dry-run, rollout waits, readiness + checks, and cross-node PubSub verification passed in the local kind cluster. +- Browser verification used headed Chrome. Fresh requester and helper pages + reported no console errors or warnings after the final rollout. + +Local generated evidence (ignored by Git): + +- `output/playwright/final-request-map-chat.png` +- `output/playwright/final-privacy-profile.png` +- `output/android/final-image-smoke.png` + +Android artifact: + +- `android/dist/who-need-help-debug.apk` +- SHA-256: + `4df66ec90e056c70f08841db2f0aa6178ba9cb64d4882478cd2cba88f5731003` +- Observed manifest values: version `0.1.0-debug`, minimum SDK 24, target and + compile SDK 37. + +## Configuration finding + +`WNH_DEBUG_BASE_URL` is a required Android build input, because the trusted +same-origin value is compiled into the debug APK. Its concrete local value is +read from the repository's ignored `.env` by `scripts/android-build.sh` and is +passed to Docker with `--build-arg`. `android/Dockerfile` only declares +`ARG WNH_DEBUG_BASE_URL`; it contains no URL default. + +The example `http://10.0.2.2:4010` remains only as unit-test data for origin +matching. It is not a runtime or build default. The ignored `.env` is also +excluded from the root Docker build context. + +## Database observations + +The final local Compose observation found 2 users, 1 help request, and 7 +messages. These are local scenario data; the database is not assumed empty. + +The migration `20260718114233` and reverse block lookup index +`blocks_blocked_id_blocker_id_index` were present. An actual `EXPLAIN ANALYZE` +for that reverse lookup selected the index. The table contained no block rows, +so this confirms query shape, not production performance. + +Exact production capacity, minimum CPU/RAM, and scaling thresholds are unknown: +there is no representative load dataset or target-environment measurement. +The Helm chart therefore does not invent resource limits or an HPA policy. + +## Known work before a public production launch + +- Configure a real public HTTPS origin and production-sign the Android app. +- Operate PostgreSQL/PostGIS with backups, recovery testing, and the required + availability model. +- Load-test representative data and traffic, then set measured pool, resource, + autoscaling, and action-limit policies. +- Add pagination or bounded loading where real measurements show that request, + chat, moderation, or leaderboard result sets require it. +- Publish jurisdiction-specific emergency contacts, privacy, retention, + prohibited-items, and voluntary-payment guidance after legal review. +- Add OAuth identity verification only if the product chooses to make verified + social accounts a trust signal. +- Design the separate future social-activity mode rather than mixing it with + urgent-help safety and ranking rules. diff --git a/lib/who_need_help.ex b/lib/who_need_help.ex new file mode 100644 index 0000000..ad0c65a --- /dev/null +++ b/lib/who_need_help.ex @@ -0,0 +1,9 @@ +defmodule WhoNeedHelp do + @moduledoc """ + WhoNeedHelp keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex new file mode 100644 index 0000000..eb44130 --- /dev/null +++ b/lib/who_need_help/accounts.ex @@ -0,0 +1,428 @@ +defmodule WhoNeedHelp.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias WhoNeedHelp.Repo + + alias WhoNeedHelp.Accounts.{Scope, SocialIdentity, User, UserToken, UserNotifier} + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + def eligible_for_trust_actions?(%User{ + confirmed_at: confirmed_at, + accepted_terms_at: accepted_terms_at, + moderation_status: :active + }) do + not is_nil(confirmed_at) and not is_nil(accepted_terms_at) + end + + def eligible_for_trust_actions?(_user), do: false + + def eligible_user_id?(user_id) do + case Repo.get(User, user_id) do + %User{} = user -> eligible_for_trust_actions?(user) + nil -> false + end + end + + def moderator?(%User{role: role}), do: role in [:moderator, :admin] + def moderator?(_user), do: false + + def moderator_authorized?(%User{id: id}) do + case Repo.get(User, id) do + %User{} = user -> moderator?(user) + nil -> false + end + end + + def moderator_authorized?(_user), do: false + + def admin?(%User{role: :admin}), do: true + def admin?(_user), do: false + + def admin_authorized?(%User{id: id}) do + case Repo.get(User, id) do + %User{} = user -> admin?(user) + nil -> false + end + end + + def admin_authorized?(_user), do: false + + def list_users_for_moderation(%Scope{user: user}) do + if moderator_authorized?(user) do + User + |> order_by([user], asc: user.moderation_status, desc: user.inserted_at) + |> Repo.all() + else + [] + end + end + + def moderate_user(%User{} = moderator, user_id, attrs) do + if moderator_authorized?(moderator) do + Repo.transact(fn -> + user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!() + + if user.id == moderator.id and attrs["moderation_status"] in ["restricted", "suspended"] do + {:error, :cannot_restrict_self} + else + with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do + if user.moderation_status == :suspended do + Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id) + end + + {:ok, user} + end + end + end) + else + {:error, :forbidden} + end + end + + def change_user_role(%User{} = admin, user_id, attrs) do + if admin_authorized?(admin) do + Repo.transact(fn -> + user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!() + requested_role = attrs["role"] || attrs[:role] + + admin_count = + Repo.aggregate(from(candidate in User, where: candidate.role == :admin), :count) + + if user.id == admin.id and requested_role not in [:admin, "admin"] and admin_count == 1 do + {:error, :last_admin} + else + user |> User.role_changeset(attrs) |> Repo.update() + end + end) + else + {:error, :forbidden} + end + end + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + def change_user_registration(%User{} = user, attrs \\ %{}, opts \\ []) do + User.registration_changeset(user, attrs, opts) + end + + def change_user_profile(%User{} = user, attrs \\ %{}) do + User.profile_changeset(user, attrs) + end + + def update_user_profile(%User{} = user, attrs) do + user + |> User.profile_changeset(attrs) + |> Repo.update() + end + + def list_social_identities(%User{id: user_id}) do + SocialIdentity + |> where([identity], identity.user_id == ^user_id) + |> order_by([identity], asc: identity.provider, asc: identity.inserted_at) + |> Repo.all() + end + + def change_social_identity(%SocialIdentity{} = identity, attrs \\ %{}) do + SocialIdentity.changeset(identity, attrs) + end + + def add_social_identity(%User{id: user_id}, attrs) do + %SocialIdentity{user_id: user_id} + |> SocialIdentity.changeset(attrs) + |> Repo.insert() + end + + def delete_social_identity(%User{id: user_id}, identity_id) do + case Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do + nil -> {:error, :not_found} + identity -> Repo.delete(identity) + end + end + + ## Settings + + @doc """ + Checks whether the user is in sudo mode. + + The user is in sudo mode when the last authentication was done no further + than 20 minutes ago. The limit can be given as second argument in minutes. + """ + def sudo_mode?(user, minutes \\ -20) + + def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do + DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute)) + end + + def sudo_mode?(_user, _minutes), do: false + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + See `WhoNeedHelp.Accounts.User.email_changeset/3` for a list of supported options. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}, opts \\ []) do + User.email_changeset(user, attrs, opts) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + Repo.transact(fn -> + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})), + {_count, _result} <- + Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do + {:ok, user} + else + _ -> {:error, :transaction_aborted} + end + end) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + See `WhoNeedHelp.Accounts.User.password_changeset/3` for a list of supported options. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}, opts \\ []) do + User.password_changeset(user, attrs, opts) + end + + @doc """ + Updates the user password. + + Returns a tuple with the updated user, as well as a list of expired tokens. + + ## Examples + + iex> update_user_password(user, %{password: ...}) + {:ok, {%User{}, [...]}} + + iex> update_user_password(user, %{password: "too short"}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, attrs) do + user + |> User.password_changeset(attrs) + |> update_user_and_delete_all_tokens() + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + + If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Gets the user with the given magic link token. + """ + def get_user_by_magic_link_token(token) do + with {:ok, query} <- UserToken.verify_magic_link_token_query(token), + {user, _token} <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Logs the user in by magic link. + + There are three cases to consider: + + 1. The user has already confirmed their email. They are logged in + and the magic link is expired. + + 2. The user has not confirmed their email and no password is set. + In this case, the user gets confirmed, logged in, and all tokens - + including session ones - are expired. In theory, no other tokens + exist but we delete all of them for best security practices. + + 3. The user has not confirmed their email but a password is set. + This cannot happen in the default implementation but may be the + source of security pitfalls. See the "Mixing magic link and password registration" section of + `mix help phx.gen.auth`. + """ + def login_user_by_magic_link(token) do + {:ok, query} = UserToken.verify_magic_link_token_query(token) + + case Repo.one(query) do + # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password + {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) -> + raise """ + magic link log in is not allowed for unconfirmed users with a password set! + + This cannot happen with the default implementation, which indicates that you + might have adapted the code to a different use case. Please make sure to read the + "Mixing magic link and password registration" section of `mix help phx.gen.auth`. + """ + + {%User{confirmed_at: nil} = user, _token} -> + user + |> User.confirm_changeset() + |> update_user_and_delete_all_tokens() + + {user, token} -> + Repo.delete!(token) + {:ok, {user, []}} + + nil -> + {:error, :not_found} + end + end + + @doc ~S""" + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Delivers the magic link login instructions to the given user. + """ + def deliver_login_instructions(%User{} = user, magic_link_url_fun) + when is_function(magic_link_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "login") + Repo.insert!(user_token) + UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token)) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"])) + :ok + end + + ## Token helper + + defp update_user_and_delete_all_tokens(changeset) do + Repo.transact(fn -> + with {:ok, user} <- Repo.update(changeset) do + tokens_to_expire = Repo.all_by(UserToken, user_id: user.id) + + Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id))) + + {:ok, {user, tokens_to_expire}} + end + end) + end +end diff --git a/lib/who_need_help/accounts/scope.ex b/lib/who_need_help/accounts/scope.ex new file mode 100644 index 0000000..a329fee --- /dev/null +++ b/lib/who_need_help/accounts/scope.ex @@ -0,0 +1,33 @@ +defmodule WhoNeedHelp.Accounts.Scope do + @moduledoc """ + Defines the scope of the caller to be used throughout the app. + + The `WhoNeedHelp.Accounts.Scope` allows public interfaces to receive + information about the caller, such as if the call is initiated from an + end-user, and if so, which user. Additionally, such a scope can carry fields + such as "super user" or other privileges for use in authorization checks, + or to ensure specific code paths can only be accessed for a given scope. + + It is useful for logging as well as for scoping pubsub subscriptions and + broadcasts when a caller subscribes to an interface or performs a particular + action. + + Feel free to extend the fields on this struct to fit the needs of + growing application requirements. + """ + + alias WhoNeedHelp.Accounts.User + + defstruct user: nil + + @doc """ + Creates a scope for the given user. + + Returns nil if no user is given. + """ + def for_user(%User{} = user) do + %__MODULE__{user: user} + end + + def for_user(nil), do: nil +end diff --git a/lib/who_need_help/accounts/social_identity.ex b/lib/who_need_help/accounts/social_identity.ex new file mode 100644 index 0000000..e42e7a9 --- /dev/null +++ b/lib/who_need_help/accounts/social_identity.ex @@ -0,0 +1,30 @@ +defmodule WhoNeedHelp.Accounts.SocialIdentity do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "social_identities" do + field :provider, Ecto.Enum, values: [:google, :instagram, :facebook, :telegram, :other] + + field :provider_uid, :string + field :profile_url, :string + field :handle, :string + field :verified_at, :utc_datetime + belongs_to :user, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime) + end + + def changeset(identity, attrs) do + identity + |> cast(attrs, [:provider, :profile_url, :handle]) + |> validate_required([:provider, :profile_url, :user_id]) + |> validate_format(:profile_url, ~r/^https?:\/\/[^\s]+$/i, + message: "must be a complete http(s) URL" + ) + |> validate_length(:profile_url, max: 500) + |> validate_length(:handle, max: 100) + |> unique_constraint([:provider, :provider_uid]) + end +end diff --git a/lib/who_need_help/accounts/user.ex b/lib/who_need_help/accounts/user.ex new file mode 100644 index 0000000..8ad5fc6 --- /dev/null +++ b/lib/who_need_help/accounts/user.ex @@ -0,0 +1,219 @@ +defmodule WhoNeedHelp.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :confirmed_at, :utc_datetime + field :authenticated_at, :utc_datetime, virtual: true + field :display_name, :string + field :bio, :string + field :locale, :string, default: "en" + + field :location_visibility, Ecto.Enum, + values: [:hidden, :approximate_public, :exact_for_active_match, :exact_public], + default: :approximate_public + + field :direct_message_policy, Ecto.Enum, + values: [:everyone, :verified_accounts, :completed_help_users, :nobody], + default: :verified_accounts + + field :role, Ecto.Enum, values: [:user, :moderator, :admin], default: :user + + field :moderation_status, Ecto.Enum, + values: [:active, :restricted, :suspended], + default: :active + + field :moderation_note, :string + field :tip_url, :string + field :accepted_terms_at, :utc_datetime + field :terms_accepted, :boolean, virtual: true, default: false + has_many :social_identities, WhoNeedHelp.Accounts.SocialIdentity + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registering or changing the email. + + It requires the email to change otherwise an error is added. + + ## Options + + * `:validate_unique` - Set to false if you don't want to validate the + uniqueness of the email, useful when displaying live validations. + Defaults to `true`. + """ + def email_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email(opts) + end + + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email, :display_name, :terms_accepted]) + |> validate_required([:display_name]) + |> validate_acceptance(:terms_accepted, + message: "you must confirm that you are 18+ and accept the rules" + ) + |> validate_length(:display_name, min: 2, max: 80) + |> validate_email(opts) + |> maybe_accept_terms() + end + + defp maybe_accept_terms(changeset) do + if get_change(changeset, :terms_accepted) == true do + put_change(changeset, :accepted_terms_at, DateTime.utc_now(:second)) + else + changeset + end + end + + def profile_changeset(user, attrs) do + user + |> cast(attrs, [ + :display_name, + :bio, + :locale, + :location_visibility, + :direct_message_policy, + :tip_url + ]) + |> validate_required([:display_name, :locale, :location_visibility, :direct_message_policy]) + |> validate_length(:display_name, min: 2, max: 80) + |> validate_length(:bio, max: 600) + |> validate_inclusion(:locale, ~w(en uk ru)) + |> validate_url(:tip_url) + end + + def moderation_changeset(user, attrs) do + user + |> cast(attrs, [:moderation_status, :moderation_note]) + |> validate_required([:moderation_status]) + |> validate_length(:moderation_note, max: 1_000) + end + + def role_changeset(user, attrs) do + user + |> cast(attrs, [:role]) + |> validate_required([:role]) + end + + defp validate_url(changeset, field) do + validate_change(changeset, field, fn ^field, value -> + case URI.parse(value) do + %URI{scheme: scheme, host: host} when scheme in ["https", "http"] and is_binary(host) -> + [] + + _ -> + [{field, "must be a full http(s) URL"}] + end + end) + end + + defp validate_email(changeset, opts) do + changeset = + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, + message: "must have the @ sign and no spaces" + ) + |> validate_length(:email, max: 160) + + if Keyword.get(opts, :validate_unique, true) do + changeset + |> unsafe_validate_unique(:email, WhoNeedHelp.Repo) + |> unique_constraint(:email) + |> validate_email_changed() + else + changeset + end + end + + defp validate_email_changed(changeset) do + if get_field(changeset, :email) && get_change(changeset, :email) == nil do + add_error(changeset, :email, "did not change") + else + changeset + end + end + + @doc """ + A user changeset for changing the password. + + It is important to validate the length of the password, as long passwords may + be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = DateTime.utc_now(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%WhoNeedHelp.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end +end diff --git a/lib/who_need_help/accounts/user_notifier.ex b/lib/who_need_help/accounts/user_notifier.ex new file mode 100644 index 0000000..3c83fcb --- /dev/null +++ b/lib/who_need_help/accounts/user_notifier.ex @@ -0,0 +1,84 @@ +defmodule WhoNeedHelp.Accounts.UserNotifier do + import Swoosh.Email + + alias WhoNeedHelp.Mailer + alias WhoNeedHelp.Accounts.User + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"WhoNeedHelp", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to log in with a magic link. + """ + def deliver_login_instructions(user, url) do + case user do + %User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url) + _ -> deliver_magic_link_instructions(user, url) + end + end + + defp deliver_magic_link_instructions(user, url) do + deliver(user.email, "Log in instructions", """ + + ============================== + + Hi #{user.email}, + + You can log into your account by visiting the URL below: + + #{url} + + If you didn't request this email, please ignore this. + + ============================== + """) + end + + defp deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end +end diff --git a/lib/who_need_help/accounts/user_token.ex b/lib/who_need_help/accounts/user_token.ex new file mode 100644 index 0000000..0bcbe25 --- /dev/null +++ b/lib/who_need_help/accounts/user_token.ex @@ -0,0 +1,158 @@ +defmodule WhoNeedHelp.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias WhoNeedHelp.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the magic link token expiry short, + # since someone with access to the email may take over the account. + @magic_link_validity_in_minutes 15 + @change_email_validity_in_days 7 + @session_validity_in_days 14 + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + field :authenticated_at, :utc_datetime + belongs_to :user, WhoNeedHelp.Accounts.User + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix's default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + dt = user.authenticated_at || DateTime.utc_now(:second) + {token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any, along with the token's creation time. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in by_token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at} + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + If found, the query returns a tuple of the form `{user, token}`. + + The given token is valid if it matches its hashed counterpart in the + database. This function also checks whether the token has expired. The context + of a magic link token is always "login". + """ + def verify_magic_link_token_query(token) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, "login"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), + where: token.sent_to == user.email, + select: {user, token} + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user_token found by the token, if any. + + This is used to validate requests to change the user + email. + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + defp by_token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end +end diff --git a/lib/who_need_help/application.ex b/lib/who_need_help/application.ex new file mode 100644 index 0000000..c524fe4 --- /dev/null +++ b/lib/who_need_help/application.ex @@ -0,0 +1,42 @@ +defmodule WhoNeedHelp.Application do + # See https://elixir.hexdocs.pm/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + common_children = [ + WhoNeedHelpWeb.Telemetry, + WhoNeedHelp.Repo, + {DNSCluster, query: Application.get_env(:who_need_help, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: WhoNeedHelp.PubSub} + ] + + role_children = + case Application.fetch_env!(:who_need_help, :app_role) do + :web -> [WhoNeedHelpWeb.Presence, WhoNeedHelpWeb.Endpoint] + :worker -> [{Oban, Application.fetch_env!(:who_need_help, Oban)}] + :migrate -> [] + end + + children = common_children ++ role_children + + # See https://elixir.hexdocs.pm/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: WhoNeedHelp.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + if Application.get_env(:who_need_help, :app_role) == :web do + WhoNeedHelpWeb.Endpoint.config_change(changed, removed) + end + + :ok + end +end diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex new file mode 100644 index 0000000..5e3924f --- /dev/null +++ b/lib/who_need_help/catalog.ex @@ -0,0 +1,297 @@ +defmodule WhoNeedHelp.Catalog do + @moduledoc "Data-driven categories, proposals, and community votes." + + import Ecto.Query + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.Scope + alias WhoNeedHelp.Catalog.{Category, CategoryProposal, CategoryVote} + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Trust + + def list_categories do + Category + |> where([c], c.active) + |> order_by([c], asc: c.sort_order, asc: c.slug) + |> Repo.all() + end + + def get_category!(id), do: Repo.get!(Category, id) + + def list_proposals do + CategoryProposal + |> where([p], p.status == :open) + |> preload([:proposer, :parent, :votes]) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + def change_proposal(%CategoryProposal{} = proposal, attrs \\ %{}) do + CategoryProposal.changeset(proposal, attrs) + end + + def propose(%Scope{user: user} = scope, attrs) do + with {:ok, _limit} <- Trust.authorize_action(scope, :category_proposal) do + Repo.transact(fn -> + with {:ok, proposal} <- + %CategoryProposal{proposer_id: user.id} + |> CategoryProposal.changeset(attrs) + |> Repo.insert(), + {:ok, _audit} <- + Trust.audit(user.id, "category_proposal.created", "category_proposal", proposal.id) do + {:ok, proposal} + end + end) + end + end + + def vote(%Scope{user: user} = scope, proposal_id) do + with {:ok, _limit} <- Trust.authorize_action(scope, :category_vote), + %CategoryProposal{status: :open} <- Repo.get(CategoryProposal, proposal_id) do + %CategoryVote{} + |> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id}) + |> Repo.insert() + else + nil -> {:error, :not_found} + %CategoryProposal{} -> {:error, :proposal_closed} + other -> other + end + end + + def unvote(%Scope{user: user}, proposal_id) do + {count, _} = + CategoryVote + |> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id) + |> Repo.delete_all() + + {:ok, count} + end + + def seed_defaults do + attrs = %{ + slug: "medicine-pickup", + names: %{ + "en" => "Medicine pickup", + "uk" => "Доставка ліків", + "ru" => "Доставка лекарств" + }, + description: "Pickup and deliver a legal medicine that is already purchased or reserved.", + sort_order: 10, + structured_fields: %{ + "fields" => [ + %{ + "key" => "pickup_status", + "type" => "select", + "required" => true, + "label" => %{ + "en" => "Medicine pickup status", + "uk" => "Статус отримання ліків", + "ru" => "Статус получения лекарства" + }, + "options" => [ + %{ + "value" => "reserved", + "label" => %{ + "en" => "Reserved", + "uk" => "Зарезервовано", + "ru" => "Зарезервировано" + } + }, + %{ + "value" => "already_paid", + "label" => %{ + "en" => "Already paid", + "uk" => "Вже оплачено", + "ru" => "Уже оплачено" + } + } + ] + } + ] + } + } + + case Repo.get_by(Category, slug: attrs.slug) do + nil -> + %Category{} |> Category.changeset(attrs) |> Repo.insert!() + + %Category{structured_fields: fields} = category when fields == %{} -> + category + |> Category.changeset(%{structured_fields: attrs.structured_fields}) + |> Repo.update!() + + category -> + category + end + end + + def structured_fields(%Category{structured_fields: %{"fields" => fields}}) + when is_list(fields), + do: fields + + def structured_fields(_category), do: [] + + def validate_structured_data(%Category{} = category, data) when is_map(data) do + fields = structured_fields(category) + allowed = MapSet.new(Enum.map(fields, & &1["key"])) + unknown = data |> Map.keys() |> Enum.reject(&MapSet.member?(allowed, &1)) + + errors = + if unknown == [], + do: [], + else: ["contains fields that are not configured for this category"] + + {clean, errors} = + Enum.reduce(fields, {%{}, errors}, fn field, {clean, errors} -> + key = field["key"] + value = data[key] + + cond do + field["required"] == true and value in [nil, ""] -> + {clean, ["#{key} is required" | errors]} + + value in [nil, ""] -> + {clean, errors} + + valid_structured_value?(field, value) -> + {Map.put(clean, key, value), errors} + + true -> + {clean, ["#{key} has an invalid value" | errors]} + end + end) + + if errors == [], do: {:ok, clean}, else: {:error, Enum.reverse(errors)} + end + + def validate_structured_data(%Category{} = category, nil), + do: validate_structured_data(category, %{}) + + def validate_structured_data(_category, _data), do: {:error, ["must be an object"]} + + def list_proposals_for_moderation(%Scope{user: user}) do + if Accounts.moderator_authorized?(user) do + CategoryProposal + |> order_by([proposal], asc: proposal.status, desc: proposal.inserted_at) + |> preload([:proposer, :parent, :merged_into, :reviewed_by, :votes]) + |> Repo.all() + else + [] + end + end + + def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do + if Accounts.moderator_authorized?(moderator) do + Repo.transact(fn -> + proposal = locked_proposal(proposal_id) + + if proposal.status == :open do + category_attrs = + category_attrs + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.put_new("parent_id", proposal.parent_id) + + with {:ok, category} <- + %Category{} |> Category.changeset(category_attrs) |> Repo.insert(), + {:ok, proposal} <- + proposal + |> CategoryProposal.moderation_changeset(%{ + status: :approved, + merged_into_id: category.id, + reviewed_by_id: moderator.id, + reviewed_at: DateTime.utc_now(:second), + moderation_note: category_attrs["moderation_note"] + }) + |> Repo.update(), + {:ok, _audit} <- + Trust.audit( + moderator.id, + "category_proposal.approved", + "category_proposal", + proposal.id, + %{"category_id" => category.id} + ) do + {:ok, %{proposal: proposal, category: category}} + end + else + {:error, :proposal_closed} + end + end) + else + {:error, :forbidden} + end + end + + def reject_proposal(%Scope{user: moderator}, proposal_id, note) do + moderate_proposal(moderator, proposal_id, :rejected, nil, note) + end + + def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do + with %Category{} <- Repo.get(Category, category_id) do + moderate_proposal(moderator, proposal_id, :merged, category_id, note) + else + nil -> {:error, :not_found} + end + end + + defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do + if Accounts.moderator_authorized?(moderator) do + Repo.transact(fn -> + proposal = locked_proposal(proposal_id) + + if proposal.status == :open do + with {:ok, proposal} <- + proposal + |> CategoryProposal.moderation_changeset(%{ + status: status, + merged_into_id: merged_into_id, + reviewed_by_id: moderator.id, + reviewed_at: DateTime.utc_now(:second), + moderation_note: note + }) + |> Repo.update(), + {:ok, _audit} <- + Trust.audit( + moderator.id, + "category_proposal.#{status}", + "category_proposal", + proposal.id, + %{"merged_into_id" => merged_into_id} + ) do + {:ok, proposal} + end + else + {:error, :proposal_closed} + end + end) + else + {:error, :forbidden} + end + end + + defp locked_proposal(proposal_id) do + CategoryProposal + |> where([proposal], proposal.id == ^proposal_id) + |> lock("FOR UPDATE") + |> Repo.one!() + end + + defp valid_structured_value?(%{"type" => "select", "options" => options}, value) + when is_list(options) do + Enum.any?(options, fn + %{"value" => allowed} -> to_string(allowed) == to_string(value) + allowed -> to_string(allowed) == to_string(value) + end) + end + + defp valid_structured_value?(%{"type" => "boolean"}, value), + do: value in [true, false, "true", "false"] + + defp valid_structured_value?(%{"type" => "text"} = field, value) when is_binary(value) do + case field["max_length"] do + max when is_integer(max) and max > 0 -> String.length(value) <= max + _ -> true + end + end + + defp valid_structured_value?(_field, _value), do: false +end diff --git a/lib/who_need_help/catalog/category.ex b/lib/who_need_help/catalog/category.ex new file mode 100644 index 0000000..7176699 --- /dev/null +++ b/lib/who_need_help/catalog/category.ex @@ -0,0 +1,120 @@ +defmodule WhoNeedHelp.Catalog.Category do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "categories" do + field :slug, :string + field :names, :map, default: %{} + field :description, :string + field :active, :boolean, default: true + field :sort_order, :integer, default: 0 + field :structured_fields, :map, default: %{} + belongs_to :parent, __MODULE__ + has_many :children, __MODULE__, foreign_key: :parent_id + + timestamps(type: :utc_datetime) + end + + def changeset(category, attrs) do + category + |> cast(attrs, [ + :slug, + :names, + :description, + :active, + :sort_order, + :structured_fields, + :parent_id + ]) + |> validate_required([:slug, :names]) + |> validate_format(:slug, ~r/^[a-z0-9-]+$/) + |> validate_length(:slug, max: 80) + |> validate_names() + |> validate_structured_fields() + |> unique_constraint(:slug) + end + + def name(%__MODULE__{names: names}, locale) do + names[to_string(locale)] || names["en"] || names |> Map.values() |> List.first() || "Help" + end + + defp validate_names(changeset) do + validate_change(changeset, :names, fn :names, names -> + if is_map(names) and map_size(names) > 0 and + Enum.all?(names, fn {locale, name} -> + is_binary(locale) and is_binary(name) and String.trim(name) != "" + end) do + [] + else + [names: "must contain at least one non-empty localized name"] + end + end) + end + + defp validate_structured_fields(changeset) do + validate_change(changeset, :structured_fields, fn :structured_fields, schema -> + case schema do + %{"fields" => fields} when is_list(fields) -> + keys = Enum.map(fields, &field_key/1) + + cond do + not Enum.all?(fields, &valid_field?/1) -> + [structured_fields: "contains an invalid field definition"] + + length(keys) != length(Enum.uniq(keys)) -> + [structured_fields: "field keys must be unique"] + + true -> + [] + end + + schema when schema == %{} -> + [] + + _ -> + [structured_fields: "must be an object with a fields array"] + end + end) + end + + defp valid_field?(%{"key" => key, "type" => type, "label" => labels} = field) + when is_binary(key) and type in ["text", "select", "boolean"] and is_map(labels) do + valid_key? = Regex.match?(~r/^[a-z][a-z0-9_]*$/, key) + valid_labels? = map_size(labels) > 0 and Enum.all?(labels, &valid_label?/1) + valid_required? = field["required"] in [nil, true, false] + + valid_type_options? = + case type do + "select" -> valid_options?(field["options"]) + "text" -> field["max_length"] in [nil] or positive_integer?(field["max_length"]) + "boolean" -> true + end + + valid_key? and valid_labels? and valid_required? and valid_type_options? + end + + defp valid_field?(_field), do: false + + defp valid_options?(options) when is_list(options) and options != [] do + Enum.all?(options, fn + %{"value" => value, "label" => labels} -> + is_binary(value) and value != "" and is_map(labels) and map_size(labels) > 0 and + Enum.all?(labels, &valid_label?/1) + + value -> + is_binary(value) and value != "" + end) + end + + defp valid_options?(_options), do: false + + defp valid_label?({locale, label}), + do: is_binary(locale) and is_binary(label) and String.trim(label) != "" + + defp field_key(%{"key" => key}), do: key + defp field_key(_field), do: nil + defp positive_integer?(value), do: is_integer(value) and value > 0 +end diff --git a/lib/who_need_help/catalog/category_proposal.ex b/lib/who_need_help/catalog/category_proposal.ex new file mode 100644 index 0000000..22f758d --- /dev/null +++ b/lib/who_need_help/catalog/category_proposal.ex @@ -0,0 +1,37 @@ +defmodule WhoNeedHelp.Catalog.CategoryProposal do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "category_proposals" do + field :proposed_name, :string + field :reason, :string + field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open + belongs_to :proposer, WhoNeedHelp.Accounts.User + belongs_to :parent, WhoNeedHelp.Catalog.Category + belongs_to :merged_into, WhoNeedHelp.Catalog.Category + belongs_to :reviewed_by, WhoNeedHelp.Accounts.User + field :reviewed_at, :utc_datetime + field :moderation_note, :string + has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id + + timestamps(type: :utc_datetime) + end + + def changeset(proposal, attrs) do + proposal + |> cast(attrs, [:proposed_name, :reason, :parent_id]) + |> validate_required([:proposed_name, :reason]) + |> validate_length(:proposed_name, min: 2, max: 80) + |> validate_length(:reason, min: 5, max: 500) + end + + def moderation_changeset(proposal, attrs) do + proposal + |> cast(attrs, [:status, :merged_into_id, :reviewed_by_id, :reviewed_at, :moderation_note]) + |> validate_required([:status, :reviewed_by_id, :reviewed_at]) + |> validate_length(:moderation_note, max: 1_000) + end +end diff --git a/lib/who_need_help/catalog/category_vote.ex b/lib/who_need_help/catalog/category_vote.ex new file mode 100644 index 0000000..4caaa91 --- /dev/null +++ b/lib/who_need_help/catalog/category_vote.ex @@ -0,0 +1,20 @@ +defmodule WhoNeedHelp.Catalog.CategoryVote do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "category_votes" do + belongs_to :proposal, WhoNeedHelp.Catalog.CategoryProposal + belongs_to :user, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime, updated_at: false) + end + + def changeset(vote, attrs) do + vote + |> cast(attrs, [:proposal_id, :user_id]) + |> validate_required([:proposal_id, :user_id]) + |> unique_constraint([:proposal_id, :user_id]) + end +end diff --git a/lib/who_need_help/catalog_moderation.ex b/lib/who_need_help/catalog_moderation.ex new file mode 100644 index 0000000..37cc7f7 --- /dev/null +++ b/lib/who_need_help/catalog_moderation.ex @@ -0,0 +1,34 @@ +defmodule WhoNeedHelp.CatalogModeration do + @moduledoc """ + Produces a PII-free snapshot for an operator-run, advisory Codex review. + + Codex never writes to the database. An administrator must review and apply + every recommendation through a separate moderation action. + """ + + alias WhoNeedHelp.Catalog + alias WhoNeedHelp.Catalog.Category + + def export_open_proposals do + proposals = + Catalog.list_proposals() + |> Enum.map(fn proposal -> + %{ + proposal_id: proposal.id, + proposed_name: proposal.proposed_name, + reason: proposal.reason, + parent_slug: parent_slug(proposal.parent), + community_votes: length(proposal.votes) + } + end) + + Jason.encode!(%{ + generated_at: DateTime.utc_now(:second), + policy: "Advisory review only. No automatic approval, rejection, merge, or database write.", + proposals: proposals + }) + end + + defp parent_slug(%Category{slug: slug}), do: slug + defp parent_slug(_), do: nil +end diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex new file mode 100644 index 0000000..db37700 --- /dev/null +++ b/lib/who_need_help/help.ex @@ -0,0 +1,528 @@ +defmodule WhoNeedHelp.Help do + @moduledoc "Authorization-aware request matching and completion workflow." + + import Ecto.Query + alias Ecto.Multi + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.Scope + alias WhoNeedHelp.Catalog + alias WhoNeedHelp.Catalog.Category + alias WhoNeedHelp.Help.{Assignment, HelpRequest} + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Trust + alias WhoNeedHelp.Trust.Block + + @topic "help:requests" + + def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic) + + def subscribe_request(id), + do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:request:#{id}") + + def notify_request_updated(request_id) do + request = get_request!(request_id) + broadcast({:request_updated, request}) + :ok + end + + def list_open_requests(%Scope{user: user}, filters \\ %{}) do + now = DateTime.utc_now(:second) + + HelpRequest + |> where( + [request], + request.status == :open and request.expires_at > ^now and is_nil(request.hidden_at) + ) + |> where( + [request], + request.requester_id not in subquery( + from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id + ) + ) + |> where( + [request], + request.requester_id not in subquery( + from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id + ) + ) + |> filter_open_requests(filters) + |> order_by([r], asc: r.expires_at) + |> preload([:category, :requester, assignment: :helper]) + |> Repo.all() + end + + def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required") + + def list_my_requests(%Scope{user: user}) do + HelpRequest + |> where([r], r.requester_id == ^user.id) + |> order_by([r], desc: r.inserted_at) + |> preload([:category, :requester, assignment: :helper]) + |> Repo.all() + end + + def get_request!(id) do + HelpRequest + |> Repo.get!(id) + |> Repo.preload([ + :category, + requester: :social_identities, + assignment: [helper: :social_identities, messages: :sender] + ]) + end + + def get_request(%Scope{user: user} = scope, id) do + case Repo.get(HelpRequest, id) do + nil -> + {:error, :not_found} + + request -> + request = get_request!(request.id) + + cond do + Accounts.moderator_authorized?(user) -> + {:ok, request} + + request.requester_id == user.id -> + {:ok, request} + + request.assignment && participant?(scope, request.assignment) -> + {:ok, request} + + not is_nil(request.hidden_at) -> + {:error, :not_found} + + Trust.blocked_between?(user.id, request.requester_id) -> + {:error, :not_found} + + true -> + {:ok, request} + end + end + end + + def change_request(%HelpRequest{} = request, attrs \\ %{}) do + request + |> HelpRequest.create_changeset(attrs) + |> validate_structured_data() + end + + def create_request(%Scope{user: user}, attrs) do + result = + with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :create_request) do + Repo.transact(fn -> + with {:ok, request} <- + %HelpRequest{requester_id: user.id} + |> HelpRequest.create_changeset(attrs) + |> validate_structured_data() + |> Repo.insert(), + {:ok, _audit} <- + Trust.audit(user.id, "request.created", "request", request.id, %{ + "urgency" => to_string(request.urgency), + "category_id" => request.category_id + }) do + {:ok, request} + end + end) + end + + with {:ok, request} <- result do + broadcast({:request_created, get_request!(request.id)}) + {:ok, request} + end + end + + def accept_request(%Scope{user: helper}, request_id) do + now = DateTime.utc_now(:second) + code = handover_code(request_id) + + result = + with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) do + Repo.transact(fn -> + request = + HelpRequest + |> where([r], r.id == ^request_id) + |> lock("FOR UPDATE") + |> Repo.one!() + + cond do + request.requester_id == helper.id -> + {:error, :own_request} + + not is_nil(request.hidden_at) -> + {:error, :not_open} + + Trust.blocked_between?(request.requester_id, helper.id) -> + {:error, :blocked} + + request.status != :open -> + {:error, :not_open} + + DateTime.compare(request.expires_at, now) != :gt -> + {:error, :expired} + + true -> + with {:ok, assignment} <- + %Assignment{} + |> Assignment.changeset(%{ + request_id: request.id, + helper_id: helper.id, + accepted_at: now, + handover_code_hash: code_hash(code) + }) + |> Repo.insert(), + {:ok, _request} <- + request |> Ecto.Changeset.change(status: :matched) |> Repo.update(), + {:ok, _audit} <- + Trust.audit(helper.id, "request.accepted", "assignment", assignment.id, %{ + "request_id" => request.id + }) do + {:ok, assignment} + end + end + end) + end + + case result do + {:ok, assignment} -> + request = get_request!(assignment.request_id) + broadcast({:request_updated, request}) + {:ok, Repo.preload(assignment, [:helper, :request])} + + other -> + other + end + end + + def start_assignment(%Scope{user: user}, assignment_id) do + transition_assignment(user, assignment_id, :start) + end + + def confirm_completion(%Scope{user: user}, assignment_id) do + transition_assignment(user, assignment_id, :confirm) + end + + def verify_handover(%Scope{user: user}, assignment_id, code) do + with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :verify_handover) do + Repo.transact(fn -> + assignment = locked_assignment(assignment_id) + request = Repo.get!(HelpRequest, assignment.request_id) + + cond do + user.id != assignment.helper_id -> + {:error, :forbidden} + + Trust.blocked_between?(assignment.helper_id, request.requester_id) -> + {:error, :blocked} + + assignment.status not in [:accepted, :in_progress] or + not is_nil(assignment.handover_verified_at) -> + {:error, :invalid_transition} + + not Plug.Crypto.secure_compare(code_hash(code), assignment.handover_code_hash) -> + {:error, :invalid_code} + + true -> + now = DateTime.utc_now(:second) + + assignment + |> Assignment.changeset(%{handover_verified_at: now}) + |> maybe_complete(request) + |> audit_assignment_transition(user.id, "handover.verified", request.id) + end + end) + end + |> after_transition() + end + + def cancel_request(%Scope{user: user}, request_id) do + with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :cancel_request) do + Repo.transact(fn -> + request = + HelpRequest + |> where([r], r.id == ^request_id) + |> lock("FOR UPDATE") + |> Repo.one!() + + if request.requester_id == user.id and request.status in [:open, :matched] do + now = DateTime.utc_now(:second) + + assignment = + Assignment + |> where([assignment], assignment.request_id == ^request.id) + |> lock("FOR UPDATE") + |> Repo.one() + + with {:ok, request} <- + request + |> Ecto.Changeset.change(status: :cancelled, cancelled_at: now) + |> Repo.update(), + {:ok, _assignment} <- cancel_assignment(assignment), + {:ok, _audit} <- + Trust.audit(user.id, "request.cancelled", "request", request.id) do + {:ok, request} + end + else + {:error, :forbidden} + end + end) + end + |> case do + {:ok, request} -> + WhoNeedHelp.Tracking.cleanup_finished_sessions() + request = get_request!(request.id) + broadcast({:request_updated, request}) + {:ok, request} + + other -> + other + end + end + + def withdraw_assignment(%Scope{user: user}, assignment_id) do + with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do + Repo.transact(fn -> + assignment = locked_assignment(assignment_id) + request = Repo.get!(HelpRequest, assignment.request_id) + + if assignment.helper_id == user.id and assignment.status in [:accepted, :in_progress] do + now = DateTime.utc_now(:second) + + with {:ok, assignment} <- + assignment + |> Assignment.changeset(%{status: :cancelled}) + |> Repo.update(), + {:ok, _request} <- + request + |> Ecto.Changeset.change(status: :cancelled, cancelled_at: now) + |> Repo.update(), + {:ok, _audit} <- + Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{ + "request_id" => request.id + }) do + {:ok, assignment} + end + else + {:error, :invalid_transition} + end + end) + end + |> after_transition() + |> case do + {:ok, _assignment} = result -> + WhoNeedHelp.Tracking.cleanup_finished_sessions() + result + + other -> + other + end + end + + def participant?(%Scope{user: user}, %Assignment{} = assignment) do + request = + case assignment.request do + %HelpRequest{} = request -> request + _ -> Repo.get!(HelpRequest, assignment.request_id) + end + + user.id in [assignment.helper_id, request.requester_id] + end + + def requester?(%Scope{user: user}, %HelpRequest{requester_id: id}), do: user.id == id + def helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id + + def request_coordinates(%Scope{} = scope, %HelpRequest{} = request) do + participant = + request.requester_id == scope.user.id or + (not is_nil(request.assignment) and participant?(scope, request.assignment)) + + if participant and request.location_visibility in [:hidden, :exact_for_active_match] do + %Geo.Point{coordinates: {lng, lat}} = request.location + %{latitude: lat, longitude: lng, exact: true} + else + HelpRequest.public_coordinates(request) + end + end + + def handover_code(request_id) do + secret = Application.fetch_env!(:who_need_help, :handover_secret) + digest = :crypto.mac(:hmac, :sha256, secret, request_id) + + digest + |> :binary.decode_unsigned() + |> rem(1_000_000) + |> Integer.to_string() + |> String.pad_leading(6, "0") + end + + defp transition_assignment(user, assignment_id, action) do + with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), action) do + Repo.transact(fn -> + assignment = locked_assignment(assignment_id) + request = Repo.get!(HelpRequest, assignment.request_id) + now = DateTime.utc_now(:second) + + if Trust.blocked_between?(assignment.helper_id, request.requester_id) do + {:error, :blocked} + else + case {action, assignment.status, user.id} do + {:start, :accepted, helper_id} when helper_id == assignment.helper_id -> + with {:ok, assignment} <- + assignment + |> Assignment.changeset(%{status: :in_progress, started_at: now}) + |> Repo.update(), + {:ok, _} <- + request |> Ecto.Changeset.change(status: :in_progress) |> Repo.update(), + {:ok, _audit} <- + Trust.audit(user.id, "assignment.started", "assignment", assignment.id, %{ + "request_id" => request.id + }) do + {:ok, assignment} + end + + {:confirm, status, user_id} when status in [:accepted, :in_progress] -> + attrs = + cond do + user_id == assignment.helper_id -> %{helper_confirmed_at: now} + user_id == request.requester_id -> %{requester_confirmed_at: now} + true -> nil + end + + if attrs do + assignment + |> Assignment.changeset(attrs) + |> maybe_complete(request) + |> audit_assignment_transition(user.id, "assignment.confirmed", request.id) + else + {:error, :forbidden} + end + + _ -> + {:error, :invalid_transition} + end + end + end) + end + |> after_transition() + end + + defp maybe_complete(changeset, request) do + assignment = Ecto.Changeset.apply_changes(changeset) + + if assignment.handover_verified_at && assignment.requester_confirmed_at && + assignment.helper_confirmed_at do + now = DateTime.utc_now(:second) + + Multi.new() + |> Multi.update( + :assignment, + Ecto.Changeset.change(changeset, status: :completed, completed_at: now) + ) + |> Multi.update( + :request, + Ecto.Changeset.change(request, status: :completed, completed_at: now) + ) + |> Repo.transaction() + |> case do + {:ok, %{assignment: assignment}} -> {:ok, assignment} + {:error, _step, reason, _changes} -> {:error, reason} + end + else + Repo.update(changeset) + end + end + + defp locked_assignment(id) do + Assignment + |> where([a], a.id == ^id) + |> lock("FOR UPDATE") + |> Repo.one!() + end + + defp code_hash(code), do: :crypto.hash(:sha256, to_string(code)) + + defp after_transition({:ok, assignment}) do + request = get_request!(assignment.request_id) + broadcast({:request_updated, request}) + if assignment.status == :completed, do: Trust.record_completion_signals(assignment) + {:ok, Repo.preload(assignment, [:helper, :request], force: true)} + end + + defp after_transition(other), do: other + + defp audit_assignment_transition({:ok, assignment}, actor_id, action, request_id) do + with {:ok, _audit} <- + Trust.audit(actor_id, action, "assignment", assignment.id, %{ + "request_id" => request_id, + "status" => to_string(assignment.status) + }), + {:ok, _completion_audit} <- + maybe_audit_completion(actor_id, assignment, request_id) do + {:ok, assignment} + end + end + + defp audit_assignment_transition(other, _actor_id, _action, _request_id), do: other + + defp maybe_audit_completion(actor_id, %Assignment{status: :completed} = assignment, request_id) do + Trust.audit(actor_id, "request.completed", "request", request_id, %{ + "assignment_id" => assignment.id + }) + end + + defp maybe_audit_completion(_actor_id, _assignment, _request_id), do: {:ok, :not_completed} + + defp cancel_assignment(nil), do: {:ok, :no_assignment} + + defp cancel_assignment(assignment) do + assignment + |> Assignment.changeset(%{status: :cancelled}) + |> Repo.update() + end + + defp broadcast(event) do + Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, event) + + request = + case event do + {_, %HelpRequest{} = request} -> request + end + + Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:request:#{request.id}", event) + end + + defp filter_open_requests(query, filters) do + query + |> maybe_filter(:category_id, filters["category_id"] || filters[:category_id]) + |> maybe_filter(:urgency, filters["urgency"] || filters[:urgency]) + end + + defp maybe_filter(query, _field, value) when value in [nil, ""], do: query + + defp maybe_filter(query, field, value), + do: where(query, [request], field(request, ^field) == ^value) + + defp validate_structured_data(changeset) do + category_id = Ecto.Changeset.get_field(changeset, :category_id) + + case category_id && Repo.get(Category, category_id) do + %Category{} = category -> + data = Ecto.Changeset.get_field(changeset, :structured_data) + + case Catalog.validate_structured_data(category, data) do + {:ok, clean} -> + Ecto.Changeset.put_change(changeset, :structured_data, clean) + + {:error, errors} -> + Ecto.Changeset.add_error( + changeset, + :structured_data, + Enum.join(errors, "; ") + ) + end + + _ -> + changeset + end + end +end diff --git a/lib/who_need_help/help/assignment.ex b/lib/who_need_help/help/assignment.ex new file mode 100644 index 0000000..63c6d9e --- /dev/null +++ b/lib/who_need_help/help/assignment.ex @@ -0,0 +1,49 @@ +defmodule WhoNeedHelp.Help.Assignment do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "help_assignments" do + field :status, Ecto.Enum, + values: [:accepted, :in_progress, :completed, :cancelled], + default: :accepted + + field :handover_code_hash, :binary + field :handover_verified_at, :utc_datetime + field :requester_confirmed_at, :utc_datetime + field :helper_confirmed_at, :utc_datetime + field :proximity_observed_at, :utc_datetime + field :helper_movement_observed_at, :utc_datetime + field :accepted_at, :utc_datetime + field :started_at, :utc_datetime + field :completed_at, :utc_datetime + belongs_to :request, WhoNeedHelp.Help.HelpRequest + belongs_to :helper, WhoNeedHelp.Accounts.User + has_many :messages, WhoNeedHelp.Messaging.Message + has_many :reviews, WhoNeedHelp.Trust.Review + + timestamps(type: :utc_datetime) + end + + def changeset(assignment, attrs) do + assignment + |> cast(attrs, [ + :request_id, + :helper_id, + :status, + :handover_code_hash, + :handover_verified_at, + :requester_confirmed_at, + :helper_confirmed_at, + :proximity_observed_at, + :helper_movement_observed_at, + :accepted_at, + :started_at, + :completed_at + ]) + |> validate_required([:request_id, :helper_id, :status, :accepted_at, :handover_code_hash]) + |> unique_constraint(:request_id) + end +end diff --git a/lib/who_need_help/help/help_request.ex b/lib/who_need_help/help/help_request.ex new file mode 100644 index 0000000..26088f1 --- /dev/null +++ b/lib/who_need_help/help/help_request.ex @@ -0,0 +1,102 @@ +defmodule WhoNeedHelp.Help.HelpRequest do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "help_requests" do + field :title, :string + field :description, :string + field :pickup_instructions, :string + field :structured_data, :map, default: %{} + field :location_label, :string + field :location, Geo.PostGIS.Geometry + + field :status, Ecto.Enum, + values: [:open, :matched, :in_progress, :completed, :cancelled, :expired], + default: :open + + field :urgency, Ecto.Enum, values: [:now, :today, :scheduled], default: :now + + field :location_visibility, Ecto.Enum, + values: [:hidden, :approximate_public, :exact_for_active_match, :exact_public], + default: :approximate_public + + field :expires_at, :utc_datetime + field :cancelled_at, :utc_datetime + field :completed_at, :utc_datetime + field :hidden_at, :utc_datetime + field :hidden_reason, :string + belongs_to :requester, WhoNeedHelp.Accounts.User + belongs_to :category, WhoNeedHelp.Catalog.Category + has_one :assignment, WhoNeedHelp.Help.Assignment, foreign_key: :request_id + + timestamps(type: :utc_datetime) + end + + def create_changeset(request, attrs) do + request + |> cast(attrs, [ + :title, + :description, + :pickup_instructions, + :structured_data, + :location_label, + :urgency, + :location_visibility, + :expires_at, + :category_id + ]) + |> put_location(attrs) + |> validate_required([ + :title, + :description, + :location_label, + :location, + :urgency, + :location_visibility, + :expires_at, + :category_id + ]) + |> validate_length(:title, min: 5, max: 120) + |> validate_length(:description, min: 10, max: 2_000) + |> validate_length(:pickup_instructions, max: 1_000) + |> validate_expiry() + end + + def moderation_changeset(request, attrs) do + request + |> cast(attrs, [:hidden_at, :hidden_reason]) + |> validate_length(:hidden_reason, max: 1_000) + end + + defp put_location(changeset, attrs) do + lat = attrs["latitude"] || attrs[:latitude] + lng = attrs["longitude"] || attrs[:longitude] + + with {lat, ""} <- Float.parse(to_string(lat)), + {lng, ""} <- Float.parse(to_string(lng)), + true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do + put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326}) + else + _ -> add_error(changeset, :location, "select a valid location") + end + end + + defp validate_expiry(changeset) do + validate_change(changeset, :expires_at, fn :expires_at, value -> + if DateTime.after?(value, DateTime.utc_now()), + do: [], + else: [expires_at: "must be in the future"] + end) + end + + def public_coordinates(%__MODULE__{location: %Geo.Point{coordinates: {lng, lat}}} = request) do + case request.location_visibility do + :hidden -> nil + :exact_public -> %{latitude: lat, longitude: lng, exact: true} + _ -> %{latitude: Float.round(lat, 2), longitude: Float.round(lng, 2), exact: false} + end + end +end diff --git a/lib/who_need_help/mailer.ex b/lib/who_need_help/mailer.ex new file mode 100644 index 0000000..245df32 --- /dev/null +++ b/lib/who_need_help/mailer.ex @@ -0,0 +1,3 @@ +defmodule WhoNeedHelp.Mailer do + use Swoosh.Mailer, otp_app: :who_need_help +end diff --git a/lib/who_need_help/messaging.ex b/lib/who_need_help/messaging.ex new file mode 100644 index 0000000..eb4a0b1 --- /dev/null +++ b/lib/who_need_help/messaging.ex @@ -0,0 +1,64 @@ +defmodule WhoNeedHelp.Messaging do + @moduledoc "Durable match chat with PubSub fan-out after commit." + + import Ecto.Query + alias WhoNeedHelp.Accounts.Scope + alias WhoNeedHelp.Help + alias WhoNeedHelp.Help.Assignment + alias WhoNeedHelp.Messaging.Message + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Trust + + def subscribe(assignment_id) do + Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "messages:#{assignment_id}") + end + + def list_messages(%Scope{} = scope, %Assignment{} = assignment) do + if Trust.eligible?(scope) and Help.participant?(scope, assignment) and + not blocked_assignment?(scope, assignment) do + Message + |> where([m], m.assignment_id == ^assignment.id) + |> order_by([m], asc: m.inserted_at) + |> preload(:sender) + |> Repo.all() + else + [] + end + end + + def send_message(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do + with {:ok, _limit} <- Trust.authorize_action(scope, :send_message), + true <- Help.participant?(scope, assignment), + false <- blocked_assignment?(scope, assignment) do + result = + %Message{assignment_id: assignment.id, sender_id: user.id} + |> Message.changeset(attrs) + |> Repo.insert() + + with {:ok, message} <- result do + message = Repo.preload(message, :sender) + + Phoenix.PubSub.broadcast( + WhoNeedHelp.PubSub, + "messages:#{assignment.id}", + {:new_message, message} + ) + + {:ok, message} + end + else + true -> {:error, :blocked} + false -> {:error, :forbidden} + other -> other + end + end + + defp blocked_assignment?(%Scope{user: user}, assignment) do + request = Repo.preload(assignment, :request).request + + counterpart_id = + if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id + + Trust.blocked_between?(user.id, counterpart_id) + end +end diff --git a/lib/who_need_help/messaging/message.ex b/lib/who_need_help/messaging/message.ex new file mode 100644 index 0000000..f735d92 --- /dev/null +++ b/lib/who_need_help/messaging/message.ex @@ -0,0 +1,22 @@ +defmodule WhoNeedHelp.Messaging.Message do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "messages" do + field :body, :string + field :read_at, :utc_datetime + belongs_to :assignment, WhoNeedHelp.Help.Assignment + belongs_to :sender, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime) + end + + def changeset(message, attrs) do + message + |> cast(attrs, [:body]) + |> validate_required([:body, :assignment_id, :sender_id]) + |> validate_length(:body, min: 1, max: 2_000) + end +end diff --git a/lib/who_need_help/postgrex_types.ex b/lib/who_need_help/postgrex_types.ex new file mode 100644 index 0000000..a0d96ba --- /dev/null +++ b/lib/who_need_help/postgrex_types.ex @@ -0,0 +1,5 @@ +Postgrex.Types.define( + WhoNeedHelp.PostgrexTypes, + [Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions(), + json: Jason +) diff --git a/lib/who_need_help/release.ex b/lib/who_need_help/release.ex new file mode 100644 index 0000000..f007609 --- /dev/null +++ b/lib/who_need_help/release.ex @@ -0,0 +1,89 @@ +defmodule WhoNeedHelp.Release do + @moduledoc """ + Used for executing DB release tasks when run in production without Mix + installed. + """ + @app :who_need_help + + import Ecto.Query + + def migrate do + load_app() + + for repo <- repos() do + {:ok, _, _} = + Ecto.Migrator.with_repo(repo, fn repo -> + Ecto.Migrator.run(repo, :up, all: true) + WhoNeedHelp.Catalog.seed_defaults() + end) + end + end + + def rollback(repo, version) do + load_app() + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + def bootstrap_admin(email) when is_binary(email) do + WhoNeedHelp.Repo.transact(fn -> + WhoNeedHelp.Repo.query!( + "SELECT pg_advisory_xact_lock(hashtext($1))", + ["who_need_help.bootstrap_admin"] + ) + + admin_count = + WhoNeedHelp.Repo.aggregate( + from(user in WhoNeedHelp.Accounts.User, where: user.role == :admin), + :count + ) + + cond do + admin_count > 0 -> + {:error, :admin_already_exists} + + user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) -> + with {:ok, user} <- + user + |> WhoNeedHelp.Accounts.User.role_changeset(%{role: :admin}) + |> WhoNeedHelp.Repo.update(), + {:ok, _audit} <- + WhoNeedHelp.Trust.audit(nil, "user.admin_bootstrapped", "user", user.id) do + {:ok, %{id: user.id, email: user.email, role: user.role}} + end + + true -> + {:error, :user_not_found} + end + end) + end + + def await_migrations do + load_app() + Enum.each(repos(), &await_repo/1) + end + + defp await_repo(repo) do + {:ok, ready?, _} = + Ecto.Migrator.with_repo(repo, fn started_repo -> + case Ecto.Migrator.migrations(started_repo) do + [] -> false + migrations -> Enum.all?(migrations, fn {status, _version, _name} -> status == :up end) + end + end) + + unless ready? do + Process.sleep(1_000) + await_repo(repo) + end + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp load_app do + # Many platforms require SSL when connecting to the database + Application.ensure_all_started(:ssl) + Application.ensure_loaded(@app) + end +end diff --git a/lib/who_need_help/repo.ex b/lib/who_need_help/repo.ex new file mode 100644 index 0000000..94866b6 --- /dev/null +++ b/lib/who_need_help/repo.ex @@ -0,0 +1,5 @@ +defmodule WhoNeedHelp.Repo do + use Ecto.Repo, + otp_app: :who_need_help, + adapter: Ecto.Adapters.Postgres +end diff --git a/lib/who_need_help/tracking.ex b/lib/who_need_help/tracking.ex new file mode 100644 index 0000000..a285cf1 --- /dev/null +++ b/lib/who_need_help/tracking.ex @@ -0,0 +1,289 @@ +defmodule WhoNeedHelp.Tracking do + @moduledoc "Consent-driven, foreground-only active match tracking." + + import Ecto.Query + alias Ecto.Multi + alias WhoNeedHelp.Accounts.Scope + alias WhoNeedHelp.Help + alias WhoNeedHelp.Help.Assignment + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Tracking.{Position, TrackingSession} + alias WhoNeedHelp.Trust + + def subscribe(assignment_id) do + Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "tracking:#{assignment_id}") + end + + def start_session( + %Scope{user: user} = scope, + %Assignment{} = assignment, + visibility \\ :active_match + ) do + with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking), + true <- Help.participant?(scope, assignment), + true <- assignment.status in [:accepted, :in_progress] do + now = DateTime.utc_now(:second) + + %TrackingSession{} + |> TrackingSession.changeset(%{ + assignment_id: assignment.id, + user_id: user.id, + visibility: visibility, + started_at: now + }) + |> Repo.insert( + on_conflict: [set: [active: true, ended_at: nil, started_at: now, visibility: visibility]], + conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"} + ) + else + false -> {:error, :forbidden} + other -> other + end + end + + def update_position(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do + with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position), + true <- Help.participant?(scope, assignment) do + result = + Repo.transact(fn -> + session = + TrackingSession + |> where( + [session], + session.assignment_id == ^assignment.id and session.user_id == ^user.id and + session.active + ) + |> lock("FOR UPDATE") + |> Repo.one() + + if session do + persist_position(session, assignment, user, attrs) + else + {:error, :tracking_not_active} + end + end) + + with {:ok, position} <- result do + Phoenix.PubSub.broadcast( + WhoNeedHelp.PubSub, + "tracking:#{assignment.id}", + {:position_updated, user.id, public_position(position)} + ) + + {:ok, position} + end + else + false -> {:error, :forbidden} + other -> other + end + end + + def stop_session(%Scope{user: user} = scope, %Assignment{} = assignment) do + if Help.participant?(scope, assignment) do + case Repo.get_by(TrackingSession, + assignment_id: assignment.id, + user_id: user.id, + active: true + ) do + nil -> + {:ok, :already_stopped} + + session -> + now = DateTime.utc_now(:second) + + Multi.new() + |> Multi.delete_all(:positions, where(Position, tracking_session_id: ^session.id)) + |> Multi.update( + :session, + TrackingSession.changeset(session, %{active: false, ended_at: now}) + ) + |> Repo.transaction() + |> case do + {:ok, _} -> + Phoenix.PubSub.broadcast( + WhoNeedHelp.PubSub, + "tracking:#{assignment.id}", + {:tracking_stopped, user.id} + ) + + {:ok, :stopped} + + {:error, _step, reason, _} -> + {:error, reason} + end + end + else + {:error, :forbidden} + end + end + + def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do + if Trust.eligible?(scope) and Help.participant?(scope, assignment) do + TrackingSession + |> where([s], s.assignment_id == ^assignment.id and s.active) + |> join(:inner, [s], p in assoc(s, :position)) + |> select([s, p], {s.user_id, p}) + |> Repo.all() + |> Map.new(fn {user_id, position} -> {user_id, public_position(position)} end) + else + %{} + end + end + + def cleanup_finished_sessions do + now = DateTime.utc_now(:second) + + session_ids = + TrackingSession + |> join(:inner, [session], assignment in Assignment, + on: assignment.id == session.assignment_id + ) + |> where( + [session, assignment], + session.active and assignment.status in [:completed, :cancelled] + ) + |> select([session], session.id) + + Repo.transaction(fn -> + {deleted_positions, _} = + Position + |> where([position], position.tracking_session_id in subquery(session_ids)) + |> Repo.delete_all() + + {ended_sessions, _} = + TrackingSession + |> where([session], session.id in subquery(session_ids)) + |> Repo.update_all(set: [active: false, ended_at: now, updated_at: now]) + + %{positions_deleted: deleted_positions, sessions_ended: ended_sessions} + end) + end + + defp public_position(%Position{ + position: %Geo.Point{coordinates: {lng, lat}}, + accuracy_meters: accuracy, + captured_at: captured_at + }) do + %{latitude: lat, longitude: lng, accuracy: accuracy, captured_at: captured_at} + end + + defp persist_position(session, assignment, user, attrs) do + previous = + Position + |> where([position], position.tracking_session_id == ^session.id) + |> lock("FOR UPDATE") + |> Repo.one() + + now = DateTime.utc_now(:second) + attrs = Map.put(attrs, "captured_at", now) + + with {:ok, position} <- + %Position{tracking_session_id: session.id} + |> Position.changeset(attrs) + |> Repo.insert( + on_conflict: {:replace, [:position, :accuracy_meters, :captured_at, :updated_at]}, + conflict_target: :tracking_session_id, + returning: true + ) do + movement = movement_evidence(previous, position) + movement_at = session.movement_observed_at || if(movement > 0, do: now) + + {:ok, session} = + session + |> TrackingSession.changeset(%{ + sample_count: session.sample_count + 1, + distance_meters: session.distance_meters + movement, + movement_observed_at: movement_at + }) + |> Repo.update() + + assignment = + Assignment + |> where([candidate], candidate.id == ^assignment.id) + |> lock("FOR UPDATE") + |> Repo.one!() + + assignment = + maybe_mark_helper_movement(assignment, user.id, session.movement_observed_at, now) + + maybe_mark_proximity(assignment, session, position, now) + {:ok, position} + end + end + + defp maybe_mark_helper_movement(assignment, user_id, movement_observed_at, now) do + if assignment.helper_id == user_id and not is_nil(movement_observed_at) and + is_nil(assignment.helper_movement_observed_at) do + assignment + |> Assignment.changeset(%{helper_movement_observed_at: now}) + |> Repo.update!() + else + assignment + end + end + + defp maybe_mark_proximity(%Assignment{proximity_observed_at: observed} = assignment, _, _, _) + when not is_nil(observed), + do: assignment + + defp maybe_mark_proximity(assignment, session, position, now) do + counterpart_positions = + TrackingSession + |> where( + [candidate], + candidate.assignment_id == ^assignment.id and candidate.active and + candidate.user_id != ^session.user_id + ) + |> join(:inner, [candidate], point in assoc(candidate, :position)) + |> select([_candidate, point], point) + |> Repo.all() + + if Enum.any?(counterpart_positions, &accuracy_envelopes_overlap?(&1, position)) do + assignment + |> Assignment.changeset(%{proximity_observed_at: now}) + |> Repo.update!() + else + assignment + end + end + + defp movement_evidence(nil, _current), do: 0.0 + + defp movement_evidence(previous, current) do + with previous_accuracy when is_number(previous_accuracy) <- previous.accuracy_meters, + current_accuracy when is_number(current_accuracy) <- current.accuracy_meters do + max(distance(previous, current) - previous_accuracy - current_accuracy, 0.0) + else + _ -> 0.0 + end + end + + defp accuracy_envelopes_overlap?(first, second) do + with first_accuracy when is_number(first_accuracy) <- first.accuracy_meters, + second_accuracy when is_number(second_accuracy) <- second.accuracy_meters do + distance(first, second) <= first_accuracy + second_accuracy + else + _ -> false + end + end + + defp distance( + %Position{position: %Geo.Point{coordinates: {first_lng, first_lat}}}, + %Position{position: %Geo.Point{coordinates: {second_lng, second_lat}}} + ) do + earth_radius_meters = 6_371_008.8 + latitude_delta = radians(second_lat - first_lat) + longitude_delta = radians(second_lng - first_lng) + + a = + :math.sin(latitude_delta / 2) ** 2 + + :math.cos(radians(first_lat)) * :math.cos(radians(second_lat)) * + :math.sin(longitude_delta / 2) ** 2 + + a = a |> max(0.0) |> min(1.0) + + earth_radius_meters * 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + end + + defp radians(degrees), do: degrees * :math.pi() / 180 +end diff --git a/lib/who_need_help/tracking/position.ex b/lib/who_need_help/tracking/position.ex new file mode 100644 index 0000000..897525e --- /dev/null +++ b/lib/who_need_help/tracking/position.ex @@ -0,0 +1,37 @@ +defmodule WhoNeedHelp.Tracking.Position do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "tracking_positions" do + field :position, Geo.PostGIS.Geometry + field :accuracy_meters, :float + field :captured_at, :utc_datetime + belongs_to :tracking_session, WhoNeedHelp.Tracking.TrackingSession + timestamps(type: :utc_datetime) + end + + def changeset(position, attrs) do + position + |> cast(attrs, [:accuracy_meters, :captured_at]) + |> put_position(attrs) + |> validate_required([:tracking_session_id, :position, :captured_at]) + |> validate_number(:accuracy_meters, greater_than_or_equal_to: 0) + |> unique_constraint(:tracking_session_id) + end + + defp put_position(changeset, attrs) do + lat = attrs["latitude"] || attrs[:latitude] + lng = attrs["longitude"] || attrs[:longitude] + + with {lat, ""} <- Float.parse(to_string(lat)), + {lng, ""} <- Float.parse(to_string(lng)), + true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do + put_change(changeset, :position, %Geo.Point{coordinates: {lng, lat}, srid: 4326}) + else + _ -> add_error(changeset, :position, "is invalid") + end + end +end diff --git a/lib/who_need_help/tracking/tracking_session.ex b/lib/who_need_help/tracking/tracking_session.ex new file mode 100644 index 0000000..620f182 --- /dev/null +++ b/lib/who_need_help/tracking/tracking_session.ex @@ -0,0 +1,46 @@ +defmodule WhoNeedHelp.Tracking.TrackingSession do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "tracking_sessions" do + field :active, :boolean, default: true + + field :visibility, Ecto.Enum, + values: [:active_match, :public], + default: :active_match + + field :started_at, :utc_datetime + field :ended_at, :utc_datetime + field :distance_meters, :float, default: 0.0 + field :sample_count, :integer, default: 0 + field :movement_observed_at, :utc_datetime + belongs_to :assignment, WhoNeedHelp.Help.Assignment + belongs_to :user, WhoNeedHelp.Accounts.User + has_one :position, WhoNeedHelp.Tracking.Position + timestamps(type: :utc_datetime) + end + + def changeset(session, attrs) do + session + |> cast(attrs, [ + :assignment_id, + :user_id, + :visibility, + :active, + :started_at, + :ended_at, + :distance_meters, + :sample_count, + :movement_observed_at + ]) + |> validate_required([:assignment_id, :user_id, :visibility, :started_at]) + |> validate_number(:distance_meters, greater_than_or_equal_to: 0) + |> validate_number(:sample_count, greater_than_or_equal_to: 0) + |> unique_constraint([:assignment_id, :user_id], + name: :tracking_sessions_one_active_per_user + ) + end +end diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex new file mode 100644 index 0000000..65172f6 --- /dev/null +++ b/lib/who_need_help/trust.ex @@ -0,0 +1,695 @@ +defmodule WhoNeedHelp.Trust do + @moduledoc "Reviews, reports, blocks, reputation, abuse signals, and auditable moderation." + + import Ecto.Query + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.{Scope, User} + alias WhoNeedHelp.Help + alias WhoNeedHelp.Help.{Assignment, HelpRequest} + alias WhoNeedHelp.Messaging.Message + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Tracking.{Position, TrackingSession} + + alias WhoNeedHelp.Trust.{ + AbuseSignal, + AuditEvent, + Block, + RateLimiter, + Report, + Review + } + + def authorize_action(%Scope{user: %User{} = user}, action) do + user = Repo.get(User, user.id) + + cond do + not Accounts.eligible_for_trust_actions?(user) -> + {:error, :account_not_eligible} + + true -> + case RateLimiter.check(action, user.id) do + {:error, :rate_limited} = error -> + create_velocity_signal_once(user.id, action) + error + + result -> + result + end + end + end + + def authorize_action(_, _action), do: {:error, :account_not_eligible} + + def eligible?(%Scope{user: %User{id: id}}), do: Accounts.eligible_user_id?(id) + def eligible?(_scope), do: false + + def submit_review(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do + attrs = stringify_keys(attrs) + + request = + case assignment.request do + %HelpRequest{} = request -> request + _ -> Repo.get!(HelpRequest, assignment.request_id) + end + + with {:ok, _limit} <- authorize_action(scope, :review), + true <- assignment.status == :completed, + true <- user.id in [assignment.helper_id, request.requester_id] do + reviewee_id = + if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id + + Repo.transact(fn -> + with {:ok, review} <- + %Review{} + |> Review.changeset( + Map.merge(attrs, %{ + "assignment_id" => assignment.id, + "reviewer_id" => user.id, + "reviewee_id" => reviewee_id + }) + ) + |> Repo.insert(), + reviews <- Repo.all(from r in Review, where: r.assignment_id == ^assignment.id), + true <- length(reviews) <= 2 do + if length(reviews) == 2 do + now = DateTime.utc_now(:second) + + Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment.id), + set: [revealed_at: now] + ) + end + + audit(user.id, "review.submitted", "assignment", assignment.id, %{ + "revealed" => length(reviews) == 2 + }) + + {:ok, review} + else + false -> {:error, :invalid_review_count} + other -> other + end + end) + else + false -> {:error, :forbidden} + other -> other + end + end + + def visible_reviews(user_id) do + Review + |> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at)) + |> order_by([review], desc: review.inserted_at) + |> preload(:reviewer) + |> Repo.all() + end + + def reputation(user_id) do + rows = completed_rows() |> Enum.filter(&participant?(&1, user_id)) + counterparts = Enum.map(rows, &counterpart(&1, user_id)) + ratings = ratings(user_id) + + %{ + completed: length(rows), + unique_people: counterparts |> Enum.uniq() |> length(), + verified_handovers: Enum.count(rows, &(not is_nil(&1.handover_verified_at))), + location_supported: + Enum.count( + rows, + &(not is_nil(&1.proximity_observed_at) and movement_for_user?(&1, user_id)) + ), + rating: average(ratings) + } + end + + def leaderboard do + ratings_by_user = + Review + |> where([review], not is_nil(review.revealed_at)) + |> group_by([review], review.reviewee_id) + |> select([review], {review.reviewee_id, avg(review.rating)}) + |> Repo.all() + |> Map.new() + + completed_rows() + |> Enum.group_by(& &1.helper_id) + |> Enum.map(fn {helper_id, rows} -> + helper = rows |> hd() |> Map.fetch!(:helper) + + supported_people = + rows + |> Enum.filter( + &(not is_nil(&1.proximity_observed_at) and + not is_nil(&1.helper_movement_observed_at)) + ) + |> Enum.map(& &1.requester_id) + |> Enum.uniq() + |> length() + + verified_people = + rows + |> Enum.filter(&(not is_nil(&1.handover_verified_at))) + |> Enum.map(& &1.requester_id) + |> Enum.uniq() + |> length() + + %{ + user: helper, + completed: length(rows), + unique_people: rows |> Enum.map(& &1.requester_id) |> Enum.uniq() |> length(), + location_supported_people: supported_people, + verified_people: verified_people, + rating: ratings_by_user |> Map.get(helper_id) |> decimal_average() + } + end) + |> Enum.sort_by(fn entry -> + { + -entry.location_supported_people, + -entry.verified_people, + -entry.unique_people, + -entry.completed, + String.downcase(entry.user.display_name || "") + } + end) + end + + def report(%Scope{user: user} = scope, attrs) do + attrs = stringify_keys(attrs) + + with {:ok, _limit} <- authorize_action(scope, :report), + :ok <- authorize_report_target(scope, attrs) do + Repo.transact(fn -> + with {:ok, report} <- + %Report{reporter_id: user.id} + |> Report.changeset(attrs) + |> Repo.insert() do + audit(user.id, "report.created", "report", report.id, %{ + "reason" => to_string(report.reason) + }) + + {:ok, report} + end + end) + end + end + + def list_reports(%Scope{user: user}, status \\ nil) do + if Accounts.moderator_authorized?(user) do + Report + |> maybe_status(status) + |> order_by([report], asc: report.status, desc: report.inserted_at) + |> preload([ + :reporter, + :reviewed_by, + request: :requester, + assignment: :helper, + message: :sender + ]) + |> Repo.all() + else + [] + end + end + + def moderate_report(%Scope{user: moderator}, report_id, attrs) do + if Accounts.moderator_authorized?(moderator) do + attrs = + attrs + |> stringify_keys() + |> Map.merge(%{ + "reviewed_at" => DateTime.utc_now(:second), + "reviewed_by_id" => moderator.id + }) + + Repo.transact(fn -> + report = + Report |> where([report], report.id == ^report_id) |> lock("FOR UPDATE") |> Repo.one!() + + with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update() do + audit(moderator.id, "report.moderated", "report", report.id, %{ + "status" => to_string(report.status) + }) + + {:ok, report} + end + end) + else + {:error, :forbidden} + end + end + + def report_evidence(%Scope{user: moderator}, report_id) do + if Accounts.moderator_authorized?(moderator) do + report = + Report + |> Repo.get(report_id) + |> Repo.preload([:reporter, :request, :assignment, message: :assignment]) + + if report do + assignment_id = + cond do + report.assignment_id -> report.assignment_id + report.message -> report.message.assignment_id + true -> nil + end + + messages = + if assignment_id do + Message + |> where([message], message.assignment_id == ^assignment_id) + |> order_by([message], asc: message.inserted_at) + |> preload(:sender) + |> Repo.all() + else + [] + end + + with {:ok, _audit} <- + audit(moderator.id, "report.evidence_viewed", "report", report.id, %{ + "assignment_id" => assignment_id, + "message_count" => length(messages) + }) do + {:ok, %{report: report, messages: messages}} + end + else + {:error, :not_found} + end + else + {:error, :forbidden} + end + end + + def block(%Scope{user: user} = scope, blocked_id) do + with {:ok, _limit} <- authorize_action(scope, :block), + %User{} <- Repo.get(User, blocked_id) do + Repo.transact(fn -> + with {:ok, block} <- + %Block{} + |> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id}) + |> Repo.insert() do + audit(user.id, "user.blocked", "user", blocked_id) + clear_pair_tracking(user.id, blocked_id) + {:ok, block} + end + end) + else + nil -> {:error, :not_found} + other -> other + end + end + + def unblock(%Scope{user: user}, blocked_id) do + case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do + nil -> + {:ok, :already_unblocked} + + block -> + Repo.transact(fn -> + with {:ok, _block} <- Repo.delete(block) do + audit(user.id, "user.unblocked", "user", blocked_id) + {:ok, :unblocked} + end + end) + end + end + + def list_blocks(%Scope{user: user}) do + Block + |> where([block], block.blocker_id == ^user.id) + |> order_by([block], desc: block.inserted_at) + |> preload(:blocked) + |> Repo.all() + end + + def blocked_between?(first_user_id, second_user_id) do + Block + |> where( + [block], + (block.blocker_id == ^first_user_id and block.blocked_id == ^second_user_id) or + (block.blocker_id == ^second_user_id and block.blocked_id == ^first_user_id) + ) + |> Repo.exists?() + end + + def blocked_by?(blocker_id, blocked_id) do + Repo.exists?( + from block in Block, + where: block.blocker_id == ^blocker_id and block.blocked_id == ^blocked_id + ) + end + + def record_completion_signals(%Assignment{} = assignment) do + request = + case assignment.request do + %HelpRequest{} = request -> request + _ -> Repo.get!(HelpRequest, assignment.request_id) + end + + repeated_pair? = + Assignment + |> join(:inner, [candidate], req in HelpRequest, on: req.id == candidate.request_id) + |> where( + [candidate, req], + candidate.status == :completed and candidate.id != ^assignment.id and + candidate.helper_id == ^assignment.helper_id and + req.requester_id == ^request.requester_id + ) + |> Repo.exists?() + + if repeated_pair? do + create_signal_once(:repeated_pair, assignment.helper_id, assignment.id, %{ + "counterpart_id" => request.requester_id + }) + end + + reciprocal? = + Assignment + |> join(:inner, [candidate], req in HelpRequest, on: req.id == candidate.request_id) + |> where( + [candidate, req], + candidate.status == :completed and candidate.helper_id == ^request.requester_id and + req.requester_id == ^assignment.helper_id + ) + |> Repo.exists?() + + if reciprocal? do + create_signal_once(:reciprocal_ring, assignment.helper_id, assignment.id, %{ + "counterpart_id" => request.requester_id, + "factual_signal_only" => true + }) + end + + if is_nil(assignment.proximity_observed_at) do + create_signal_once( + :handover_without_location_evidence, + assignment.helper_id, + assignment.id, + %{"tracking_optional" => true} + ) + end + + if is_nil(assignment.helper_movement_observed_at) do + create_signal_once(:location_without_movement, assignment.helper_id, assignment.id, %{ + "tracking_optional" => true + }) + end + + :ok + end + + def list_abuse_signals(%Scope{user: user}, status \\ :open) do + if Accounts.moderator_authorized?(user) do + AbuseSignal + |> where([signal], signal.status == ^status) + |> order_by([signal], desc: signal.inserted_at) + |> preload([:subject, :assignment, :reviewed_by]) + |> Repo.all() + else + [] + end + end + + def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do + if Accounts.moderator_authorized?(moderator) do + attrs = + attrs + |> stringify_keys() + |> Map.merge(%{ + "reviewed_at" => DateTime.utc_now(:second), + "reviewed_by_id" => moderator.id + }) + + Repo.transact(fn -> + signal = + AbuseSignal + |> where([signal], signal.id == ^signal_id) + |> lock("FOR UPDATE") + |> Repo.one!() + + with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update() do + audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{ + "status" => to_string(signal.status) + }) + + {:ok, signal} + end + end) + else + {:error, :forbidden} + end + end + + def hide_request(%Scope{user: moderator}, request_id, reason) do + result = + if Accounts.moderator_authorized?(moderator) do + Repo.transact(fn -> + request = + HelpRequest + |> where([request], request.id == ^request_id) + |> lock("FOR UPDATE") + |> Repo.one!() + + with {:ok, request} <- + request + |> HelpRequest.moderation_changeset(%{ + hidden_at: DateTime.utc_now(:second), + hidden_reason: reason + }) + |> Repo.update() do + audit(moderator.id, "request.hidden", "request", request.id, %{"reason" => reason}) + {:ok, request} + end + end) + else + {:error, :forbidden} + end + + with {:ok, request} <- result do + Help.notify_request_updated(request.id) + {:ok, request} + end + end + + def restore_request(%Scope{user: moderator}, request_id) do + result = + if Accounts.moderator_authorized?(moderator) do + Repo.transact(fn -> + request = + HelpRequest + |> where([request], request.id == ^request_id) + |> lock("FOR UPDATE") + |> Repo.one!() + + with {:ok, request} <- + request + |> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil}) + |> Repo.update() do + audit(moderator.id, "request.restored", "request", request.id) + {:ok, request} + end + end) + else + {:error, :forbidden} + end + + with {:ok, request} <- result do + Help.notify_request_updated(request.id) + {:ok, request} + end + end + + def moderate_user(%Scope{user: moderator}, user_id, attrs) do + Repo.transact(fn -> + with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs), + {:ok, _audit} <- + audit(moderator.id, "user.moderated", "user", user.id, %{ + "moderation_status" => to_string(user.moderation_status) + }) do + {:ok, user} + end + end) + end + + def moderate_role(%Scope{user: admin}, user_id, attrs) do + Repo.transact(fn -> + with {:ok, user} <- Accounts.change_user_role(admin, user_id, attrs), + {:ok, _audit} <- + audit(admin.id, "user.role_changed", "user", user.id, %{ + "role" => to_string(user.role) + }) do + {:ok, user} + end + end) + end + + def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do + %AuditEvent{} + |> AuditEvent.changeset(%{ + actor_id: actor_id, + action: action, + target_type: target_type, + target_id: target_id, + metadata: metadata + }) + |> Repo.insert() + end + + defp completed_rows do + Assignment + |> join(:inner, [assignment], request in HelpRequest, on: request.id == assignment.request_id) + |> join(:inner, [assignment, _request], helper in User, on: helper.id == assignment.helper_id) + |> where([assignment], assignment.status == :completed) + |> select([assignment, request, helper], %{ + id: assignment.id, + helper_id: assignment.helper_id, + requester_id: request.requester_id, + handover_verified_at: assignment.handover_verified_at, + proximity_observed_at: assignment.proximity_observed_at, + helper_movement_observed_at: assignment.helper_movement_observed_at, + helper: helper + }) + |> Repo.all() + end + + defp participant?(row, user_id), do: row.helper_id == user_id or row.requester_id == user_id + + defp counterpart(row, user_id), + do: if(row.helper_id == user_id, do: row.requester_id, else: row.helper_id) + + defp movement_for_user?(row, user_id) do + if row.helper_id == user_id, do: not is_nil(row.helper_movement_observed_at), else: true + end + + defp ratings(user_id) do + Review + |> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at)) + |> select([review], review.rating) + |> Repo.all() + end + + defp average([]), do: nil + defp average(values), do: Float.round(Enum.sum(values) / length(values), 1) + defp decimal_average(nil), do: nil + defp decimal_average(value), do: value |> Decimal.to_float() |> Float.round(1) + + defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id}) + when is_binary(request_id) do + case Repo.get(HelpRequest, request_id) do + %HelpRequest{requester_id: requester_id} when requester_id != user.id -> :ok + %HelpRequest{} -> {:error, :cannot_report_self} + nil -> {:error, :not_found} + end + end + + defp authorize_report_target(scope, %{"assignment_id" => assignment_id}) + when is_binary(assignment_id) do + case Repo.get(Assignment, assignment_id) do + %Assignment{} = assignment -> + if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden} + + nil -> + {:error, :not_found} + end + end + + defp authorize_report_target(scope, %{"message_id" => message_id}) when is_binary(message_id) do + case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do + %Message{sender_id: sender_id, assignment: assignment} -> + cond do + sender_id == scope.user.id -> {:error, :cannot_report_self} + Help.participant?(scope, assignment) -> :ok + true -> {:error, :forbidden} + end + + nil -> + {:error, :not_found} + end + end + + defp authorize_report_target(_scope, _attrs), do: {:error, :invalid_target} + + defp create_signal_once(kind, subject_id, assignment_id, metadata) do + query = + from signal in AbuseSignal, + where: + signal.kind == ^kind and signal.subject_id == ^subject_id and + signal.assignment_id == ^assignment_id + + if Repo.exists?(query) do + {:ok, :already_recorded} + else + %AbuseSignal{} + |> AbuseSignal.changeset(%{ + kind: kind, + subject_id: subject_id, + assignment_id: assignment_id, + metadata: metadata + }) + |> Repo.insert() + end + end + + defp create_velocity_signal_once(subject_id, action) do + query = + from signal in AbuseSignal, + where: + signal.kind == :velocity and signal.subject_id == ^subject_id and + signal.status == :open + + case Repo.one(query) do + nil -> + %AbuseSignal{} + |> AbuseSignal.changeset(%{ + kind: :velocity, + subject_id: subject_id, + metadata: %{"action" => to_string(action)} + }) + |> Repo.insert() + + signal -> + signal + |> Ecto.Changeset.change( + metadata: Map.put(signal.metadata, "latest_action", to_string(action)) + ) + |> Repo.update() + end + end + + defp maybe_status(query, nil), do: query + defp maybe_status(query, status), do: where(query, [report], report.status == ^status) + + defp clear_pair_tracking(first_user_id, second_user_id) do + assignment_ids = + Assignment + |> join(:inner, [assignment], request in HelpRequest, + on: request.id == assignment.request_id + ) + |> where( + [assignment, request], + (assignment.helper_id == ^first_user_id and request.requester_id == ^second_user_id) or + (assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id) + ) + |> where([assignment], assignment.status in [:accepted, :in_progress]) + |> select([assignment], assignment.id) + + session_ids = + TrackingSession + |> where([session], session.active and session.assignment_id in subquery(assignment_ids)) + |> select([session], session.id) + + Repo.delete_all( + from position in Position, where: position.tracking_session_id in subquery(session_ids) + ) + + Repo.update_all( + from(session in TrackingSession, where: session.id in subquery(session_ids)), + set: [active: false, ended_at: DateTime.utc_now(:second)] + ) + + :ok + end + + defp stringify_keys(attrs) do + Map.new(attrs, fn {key, value} -> {to_string(key), value} end) + end +end diff --git a/lib/who_need_help/trust/abuse_signal.ex b/lib/who_need_help/trust/abuse_signal.ex new file mode 100644 index 0000000..ad45c4a --- /dev/null +++ b/lib/who_need_help/trust/abuse_signal.ex @@ -0,0 +1,44 @@ +defmodule WhoNeedHelp.Trust.AbuseSignal do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "abuse_signals" do + field :kind, Ecto.Enum, + values: [ + :velocity, + :repeated_pair, + :handover_without_location_evidence, + :location_without_movement, + :reciprocal_ring, + :manual + ] + + field :status, Ecto.Enum, values: [:open, :reviewed, :dismissed], default: :open + field :metadata, :map, default: %{} + field :reviewed_at, :utc_datetime + field :review_note, :string + belongs_to :subject, WhoNeedHelp.Accounts.User + belongs_to :assignment, WhoNeedHelp.Help.Assignment + belongs_to :reviewed_by, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime) + end + + def changeset(signal, attrs) do + signal + |> cast(attrs, [:kind, :status, :metadata, :subject_id, :assignment_id]) + |> validate_required([:kind, :subject_id]) + |> unique_constraint([:kind, :subject_id, :assignment_id], + name: :abuse_signals_one_kind_per_assignment + ) + end + + def moderation_changeset(signal, attrs) do + signal + |> cast(attrs, [:status, :reviewed_at, :review_note, :reviewed_by_id]) + |> validate_required([:status, :reviewed_at, :reviewed_by_id]) + |> validate_length(:review_note, max: 1_000) + end +end diff --git a/lib/who_need_help/trust/audit_event.ex b/lib/who_need_help/trust/audit_event.ex new file mode 100644 index 0000000..324dd7e --- /dev/null +++ b/lib/who_need_help/trust/audit_event.ex @@ -0,0 +1,22 @@ +defmodule WhoNeedHelp.Trust.AuditEvent do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "audit_events" do + field :action, :string + field :target_type, :string + field :target_id, :binary_id + field :metadata, :map, default: %{} + belongs_to :actor, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime, updated_at: false) + end + + def changeset(event, attrs) do + event + |> cast(attrs, [:action, :target_type, :target_id, :metadata, :actor_id]) + |> validate_required([:action, :target_type]) + end +end diff --git a/lib/who_need_help/trust/block.ex b/lib/who_need_help/trust/block.ex new file mode 100644 index 0000000..a0a6e0d --- /dev/null +++ b/lib/who_need_help/trust/block.ex @@ -0,0 +1,21 @@ +defmodule WhoNeedHelp.Trust.Block do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "blocks" do + belongs_to :blocker, WhoNeedHelp.Accounts.User + belongs_to :blocked, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime, updated_at: false) + end + + def changeset(block, attrs) do + block + |> cast(attrs, [:blocker_id, :blocked_id]) + |> validate_required([:blocker_id, :blocked_id]) + |> unique_constraint([:blocker_id, :blocked_id]) + |> check_constraint(:blocked_id, name: :block_cannot_target_self) + end +end diff --git a/lib/who_need_help/trust/rate_limit_bucket.ex b/lib/who_need_help/trust/rate_limit_bucket.ex new file mode 100644 index 0000000..74779e4 --- /dev/null +++ b/lib/who_need_help/trust/rate_limit_bucket.ex @@ -0,0 +1,25 @@ +defmodule WhoNeedHelp.Trust.RateLimitBucket do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "rate_limit_buckets" do + field :action, :string + field :scope_hash, :binary + field :window_started_at, :utc_datetime + field :count, :integer, default: 0 + field :expires_at, :utc_datetime + timestamps(type: :utc_datetime) + end + + def changeset(bucket, attrs) do + bucket + |> cast(attrs, [:action, :scope_hash, :window_started_at, :count, :expires_at]) + |> validate_required([:action, :scope_hash, :window_started_at, :count, :expires_at]) + |> validate_length(:action, min: 1, max: 80) + |> validate_number(:count, greater_than_or_equal_to: 0) + |> unique_constraint([:action, :scope_hash, :window_started_at]) + end +end diff --git a/lib/who_need_help/trust/rate_limiter.ex b/lib/who_need_help/trust/rate_limiter.ex new file mode 100644 index 0000000..34958bf --- /dev/null +++ b/lib/who_need_help/trust/rate_limiter.ex @@ -0,0 +1,84 @@ +defmodule WhoNeedHelp.Trust.RateLimiter do + @moduledoc """ + Shared PostgreSQL-backed action limits. + + Policies are opt-in and supplied as a map whose values contain positive + `limit` and `window_seconds` integers. No product thresholds are assumed. + """ + + import Ecto.Query + + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Trust.RateLimitBucket + + def check(action, scope) when is_atom(action), do: check(Atom.to_string(action), scope) + + def check(action, scope) when is_binary(action) do + case policy(action) do + nil -> + {:ok, :not_configured} + + %{limit: limit, window_seconds: window_seconds} -> + increment(action, scope, limit, window_seconds) + end + end + + def prune_expired do + now = DateTime.utc_now(:second) + Repo.delete_all(from bucket in RateLimitBucket, where: bucket.expires_at <= ^now) + end + + defp policy(action) do + policies = Application.get_env(:who_need_help, :rate_limit_policies, %{}) + raw = Map.get(policies, action) || Map.get(policies, String.to_existing_atom(action)) + + case raw do + %{limit: limit, window_seconds: window} + when is_integer(limit) and limit > 0 and is_integer(window) and window > 0 -> + %{limit: limit, window_seconds: window} + + %{"limit" => limit, "window_seconds" => window} + when is_integer(limit) and limit > 0 and is_integer(window) and window > 0 -> + %{limit: limit, window_seconds: window} + + _ -> + nil + end + rescue + ArgumentError -> nil + end + + defp increment(action, scope, limit, window_seconds) do + now = DateTime.utc_now(:second) + unix = DateTime.to_unix(now) + window_start = (div(unix, window_seconds) * window_seconds) |> DateTime.from_unix!() + expires_at = DateTime.add(window_start, window_seconds, :second) + scope_hash = :crypto.hash(:sha256, to_string(scope)) + + {_count, [bucket]} = + Repo.insert_all( + RateLimitBucket, + [ + %{ + id: Ecto.UUID.generate(), + action: action, + scope_hash: scope_hash, + window_started_at: window_start, + count: 1, + expires_at: expires_at, + inserted_at: now, + updated_at: now + } + ], + on_conflict: [inc: [count: 1], set: [expires_at: expires_at, updated_at: now]], + conflict_target: [:action, :scope_hash, :window_started_at], + returning: [:count] + ) + + if bucket.count <= limit do + {:ok, %{count: bucket.count, limit: limit, resets_at: expires_at}} + else + {:error, :rate_limited} + end + end +end diff --git a/lib/who_need_help/trust/report.ex b/lib/who_need_help/trust/report.ex new file mode 100644 index 0000000..1cbd13c --- /dev/null +++ b/lib/who_need_help/trust/report.ex @@ -0,0 +1,57 @@ +defmodule WhoNeedHelp.Trust.Report do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "reports" do + field :reason, Ecto.Enum, + values: [ + :dangerous_request, + :harassment, + :fraud, + :prohibited_item, + :spam, + :impersonation, + :other + ] + + field :details, :string + field :status, Ecto.Enum, values: [:open, :reviewing, :resolved, :dismissed], default: :open + field :resolution_note, :string + field :reviewed_at, :utc_datetime + belongs_to :reporter, WhoNeedHelp.Accounts.User + belongs_to :reviewed_by, WhoNeedHelp.Accounts.User + belongs_to :request, WhoNeedHelp.Help.HelpRequest + belongs_to :assignment, WhoNeedHelp.Help.Assignment + belongs_to :message, WhoNeedHelp.Messaging.Message + timestamps(type: :utc_datetime) + end + + def changeset(report, attrs) do + report + |> cast(attrs, [:reason, :details, :request_id, :assignment_id, :message_id]) + |> validate_required([:reason, :details, :reporter_id]) + |> validate_length(:details, min: 5, max: 2_000) + |> validate_single_target() + |> check_constraint(:request_id, name: :report_exactly_one_target) + end + + def moderation_changeset(report, attrs) do + report + |> cast(attrs, [:status, :resolution_note, :reviewed_at, :reviewed_by_id]) + |> validate_required([:status, :reviewed_at, :reviewed_by_id]) + |> validate_length(:resolution_note, max: 2_000) + end + + defp validate_single_target(changeset) do + targets = + [:request_id, :assignment_id, :message_id] + |> Enum.count(&(not is_nil(get_field(changeset, &1)))) + + if targets == 1, + do: changeset, + else: add_error(changeset, :request_id, "select exactly one report target") + end +end diff --git a/lib/who_need_help/trust/review.ex b/lib/who_need_help/trust/review.ex new file mode 100644 index 0000000..3311132 --- /dev/null +++ b/lib/who_need_help/trust/review.ex @@ -0,0 +1,27 @@ +defmodule WhoNeedHelp.Trust.Review do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "reviews" do + field :rating, :integer + field :comment, :string + field :revealed_at, :utc_datetime + belongs_to :assignment, WhoNeedHelp.Help.Assignment + belongs_to :reviewer, WhoNeedHelp.Accounts.User + belongs_to :reviewee, WhoNeedHelp.Accounts.User + timestamps(type: :utc_datetime) + end + + def changeset(review, attrs) do + review + |> cast(attrs, [:rating, :comment, :assignment_id, :reviewer_id, :reviewee_id]) + |> validate_required([:rating, :assignment_id, :reviewer_id, :reviewee_id]) + |> validate_number(:rating, greater_than_or_equal_to: 1, less_than_or_equal_to: 5) + |> validate_length(:comment, max: 800) + |> unique_constraint([:assignment_id, :reviewer_id]) + |> check_constraint(:reviewer_id, name: :review_cannot_target_self) + end +end diff --git a/lib/who_need_help/workers/expire_requests.ex b/lib/who_need_help/workers/expire_requests.ex new file mode 100644 index 0000000..bbb1b6c --- /dev/null +++ b/lib/who_need_help/workers/expire_requests.ex @@ -0,0 +1,63 @@ +defmodule WhoNeedHelp.Workers.ExpireRequests do + use Oban.Worker, queue: :maintenance, unique: [period: 55] + + import Ecto.Query + alias WhoNeedHelp.Help.HelpRequest + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Tracking + alias WhoNeedHelp.Trust + alias WhoNeedHelp.Trust.RateLimiter + + @impl Oban.Worker + def perform(_job) do + now = DateTime.utc_now(:second) + + ids = + HelpRequest + |> where([r], r.status == :open and r.expires_at <= ^now) + |> select([request], request.id) + |> Repo.all() + + expired = + Enum.count(ids, fn id -> + Repo.transact(fn -> + request = + HelpRequest + |> where( + [request], + request.id == ^id and request.status == :open and request.expires_at <= ^now + ) + |> lock("FOR UPDATE") + |> Repo.one() + + if request do + with {:ok, request} <- + request + |> Ecto.Changeset.change(status: :expired) + |> Repo.update(), + {:ok, _audit} <- + Trust.audit(nil, "request.expired", "request", request.id) do + {:ok, request} + end + else + {:error, :already_transitioned} + end + end) + |> case do + {:ok, _request} -> true + _other -> false + end + end) + + {:ok, cleanup} = Tracking.cleanup_finished_sessions() + {pruned_buckets, _} = RateLimiter.prune_expired() + + {:ok, + %{ + expired: expired, + tracking_positions_deleted: cleanup.positions_deleted, + tracking_sessions_ended: cleanup.sessions_ended, + rate_limit_buckets_pruned: pruned_buckets + }} + end +end diff --git a/lib/who_need_help_web.ex b/lib/who_need_help_web.ex new file mode 100644 index 0000000..a81e1cf --- /dev/null +++ b/lib/who_need_help_web.ex @@ -0,0 +1,115 @@ +defmodule WhoNeedHelpWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use WhoNeedHelpWeb, :controller + use WhoNeedHelpWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, + do: ~w(assets fonts images favicon.ico manifest.webmanifest robots.txt sw.js) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, formats: [:html, :json] + + use Gettext, backend: WhoNeedHelpWeb.Gettext + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # Translation + use Gettext, backend: WhoNeedHelpWeb.Gettext + + # HTML escaping functionality + import Phoenix.HTML + # Core UI components + import WhoNeedHelpWeb.CoreComponents + + # Common modules used in templates + alias Phoenix.LiveView.JS + alias WhoNeedHelpWeb.Layouts + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: WhoNeedHelpWeb.Endpoint, + router: WhoNeedHelpWeb.Router, + statics: WhoNeedHelpWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/who_need_help_web/components/core_components.ex b/lib/who_need_help_web/components/core_components.ex new file mode 100644 index 0000000..2eff0b0 --- /dev/null +++ b/lib/who_need_help_web/components/core_components.ex @@ -0,0 +1,505 @@ +defmodule WhoNeedHelpWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as tables, forms, and + inputs. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The foundation for styling is Tailwind CSS, a utility-first CSS framework, + augmented with daisyUI, a Tailwind CSS plugin that provides UI components + and themes. Here are useful references: + + * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get + started and see the available components. + + * [Tailwind CSS](https://tailwindcss.com) - the foundational framework + we build on. You will use it for layout, sizing, flexbox, grid, and + spacing. + + * [Heroicons](https://heroicons.com) - see `icon/1` for usage. + + * [Phoenix.Component](https://phoenix-live-view.hexdocs.pm/Phoenix.Component.html) - + the component system used by Phoenix. Some components, such as `<.link>` + and `<.form>`, are defined there. + + """ + use Phoenix.Component + use Gettext, backend: WhoNeedHelpWeb.Gettext + + alias Phoenix.LiveView.JS + + @doc """ + Renders flash notices. + + ## Examples + + <.flash kind={:info} flash={@flash} /> + <.flash + id="welcome-back" + kind={:info} + phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")} + hidden + > + Welcome Back! + + """ + attr :id, :string, doc: "the optional id of flash container" + attr :flash, :map, default: %{}, doc: "the map of flash messages to display" + attr :title, :string, default: nil + attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" + + slot :inner_block, doc: "the optional inner block that renders the flash message" + + def flash(assigns) do + assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) + + ~H""" +
hide("##{@id}")} + role="alert" + class="toast toast-bottom toast-end z-50" + {@rest} + > +
+ <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> + <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> +
+

{@title}

+

{msg}

+
+
+ +
+
+ """ + end + + @doc """ + Renders a button with navigation support. + + ## Examples + + <.button>Send! + <.button phx-click="go" variant="primary">Send! + <.button navigate={~p"/"}>Home + """ + attr :rest, :global, include: ~w(href navigate patch method download name value disabled) + attr :class, :any + attr :variant, :string, values: ~w(primary) + slot :inner_block, required: true + + def button(%{rest: rest} = assigns) do + variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} + + assigns = + assign_new(assigns, :class, fn -> + ["btn", Map.fetch!(variants, assigns[:variant])] + end) + + if rest[:href] || rest[:navigate] || rest[:patch] do + ~H""" + <.link class={@class} {@rest}> + {render_slot(@inner_block)} + + """ + else + ~H""" + + """ + end + end + + @doc """ + Renders an input with label and error messages. + + A `Phoenix.HTML.FormField` may be passed as argument, + which is used to retrieve the input name, id, and values. + Otherwise all attributes may be passed explicitly. + + ## Types + + This function accepts all HTML input types, considering that: + + * You may also set `type="select"` to render a ` + """ + end + + def input(%{type: "checkbox"} = assigns) do + assigns = + assign_new(assigns, :checked, fn -> + Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + end) + + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # Helper used by inputs to generate form errors + defp error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle" class="size-5" /> + {render_slot(@inner_block)} +

+ """ + end + + @doc """ + Renders a header with title. + """ + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ {render_slot(@inner_block)} +

+

+ {render_slot(@subtitle)} +

+
+
{render_slot(@actions)}
+
+ """ + end + + @doc """ + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id">{user.id} + <:col :let={user} label="username">{user.username} + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" + + + + + + + + + + + + + +
{col[:label]} + {gettext("Actions")} +
+ {render_slot(col, @row_item.(row))} + +
+ <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> +
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title">{@post.title} + <:item title="Views">{@post.views} + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
    +
  • +
    +
    {item.title}
    +
    {render_slot(item)}
    +
    +
  • +
+ """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in `assets/vendor/heroicons.js`. + + ## Examples + + <.icon name="hero-x-mark" /> + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :any, default: "size-4" + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(WhoNeedHelpWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(WhoNeedHelpWeb.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/lib/who_need_help_web/components/layouts.ex b/lib/who_need_help_web/components/layouts.ex new file mode 100644 index 0000000..6abd614 --- /dev/null +++ b/lib/who_need_help_web/components/layouts.ex @@ -0,0 +1,193 @@ +defmodule WhoNeedHelpWeb.Layouts do + @moduledoc """ + This module holds layouts and related functionality + used by your application. + """ + use WhoNeedHelpWeb, :html + + # Embed all files in layouts/* within this module. + # The default root.html.heex file contains the HTML + # skeleton of your application, namely HTML headers + # and other static content. + embed_templates "layouts/*" + + @doc """ + Renders your app layout. + + This function is typically invoked from every template, + and it often contains your application menu, sidebar, + or similar. + + ## Examples + + +

Content

+
+ + """ + attr :flash, :map, required: true, doc: "the map of flash messages" + + attr :current_scope, :map, + default: nil, + doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)" + + slot :inner_block, required: true + + def app(assigns) do + ~H""" + + +
+
+ {render_slot(@inner_block)} +
+
+ +
+ <.link href={~p"/safety"} class="link">Safety rules + · + <.link href={~p"/feedback"} class="link">Build Week feedback +
+ + <.flash_group flash={@flash} /> + """ + end + + @doc """ + Shows the flash group with standard titles and content. + + ## Examples + + <.flash_group flash={@flash} /> + """ + attr :flash, :map, required: true, doc: "the map of flash messages" + attr :id, :string, default: "flash-group", doc: "the optional id of flash container" + + def flash_group(assigns) do + ~H""" +
+ <.flash kind={:info} flash={@flash} /> + <.flash kind={:error} flash={@flash} /> + + <.flash + id="client-error" + kind={:error} + title={gettext("We can't find the internet")} + phx-disconnected={ + show(".phx-client-error #client-error") + |> JS.remove_attribute("hidden", to: ".phx-client-error #client-error") + } + phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Attempting to reconnect")} + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + + + <.flash + id="server-error" + kind={:error} + title={gettext("Something went wrong!")} + phx-disconnected={ + show(".phx-server-error #server-error") + |> JS.remove_attribute("hidden", to: ".phx-server-error #server-error") + } + phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Attempting to reconnect")} + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + +
+ """ + end + + @doc """ + Provides dark vs light theme toggle based on themes defined in app.css. + + See in root.html.heex which applies the theme before page load. + """ + def theme_toggle(assigns) do + ~H""" +
+
+ + + + + + +
+ """ + end +end diff --git a/lib/who_need_help_web/components/layouts/root.html.heex b/lib/who_need_help_web/components/layouts/root.html.heex new file mode 100644 index 0000000..0f8c87e --- /dev/null +++ b/lib/who_need_help_web/components/layouts/root.html.heex @@ -0,0 +1,80 @@ + + + + + + + + <.live_title default="Who Need Help" suffix=" · Who Need Help" phx-no-format>{assigns[:page_title]} + + + + + + + + + {@inner_content} + + diff --git a/lib/who_need_help_web/controllers/error_html.ex b/lib/who_need_help_web/controllers/error_html.ex new file mode 100644 index 0000000..b35314d --- /dev/null +++ b/lib/who_need_help_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule WhoNeedHelpWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use WhoNeedHelpWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/who_need_help_web/controllers/error_html/404.html.heex + # * lib/who_need_help_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/who_need_help_web/controllers/error_json.ex b/lib/who_need_help_web/controllers/error_json.ex new file mode 100644 index 0000000..1ef72f1 --- /dev/null +++ b/lib/who_need_help_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule WhoNeedHelpWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/who_need_help_web/controllers/feedback_controller.ex b/lib/who_need_help_web/controllers/feedback_controller.ex new file mode 100644 index 0000000..235260d --- /dev/null +++ b/lib/who_need_help_web/controllers/feedback_controller.ex @@ -0,0 +1,9 @@ +defmodule WhoNeedHelpWeb.FeedbackController do + use WhoNeedHelpWeb, :controller + + def show(conn, _params) do + render(conn, :show, + session_id: Application.get_env(:who_need_help, :codex_session_id, "not-configured") + ) + end +end diff --git a/lib/who_need_help_web/controllers/feedback_html.ex b/lib/who_need_help_web/controllers/feedback_html.ex new file mode 100644 index 0000000..5d6b25f --- /dev/null +++ b/lib/who_need_help_web/controllers/feedback_html.ex @@ -0,0 +1,4 @@ +defmodule WhoNeedHelpWeb.FeedbackHTML do + use WhoNeedHelpWeb, :html + embed_templates "feedback_html/*" +end diff --git a/lib/who_need_help_web/controllers/feedback_html/show.html.heex b/lib/who_need_help_web/controllers/feedback_html/show.html.heex new file mode 100644 index 0000000..1d79a3f --- /dev/null +++ b/lib/who_need_help_web/controllers/feedback_html/show.html.heex @@ -0,0 +1,29 @@ + +
+
OPENAI BUILD WEEK
+

Build feedback

+

+ Who Need Help was developed with the local Codex CLI authenticated through the + builder's ChatGPT subscription. No OpenAI API key or usage-based API fallback is used. +

+
+
+ Main Codex session ID +
+ {@session_id} +
+

+ Set CODEX_SESSION_ID in the deployment environment before the submission recording. +

+
+

What Codex contributed

+

+ Product boundaries, architecture decisions, Phoenix contexts, migrations, LiveView flows, Docker/Helm packaging, and verification. +

+

Human decisions retained

+

+ Safety policy, category approval, account moderation, public launch, and every production infrastructure change remain human-controlled. +

+
+
+
diff --git a/lib/who_need_help_web/controllers/health_controller.ex b/lib/who_need_help_web/controllers/health_controller.ex new file mode 100644 index 0000000..9727731 --- /dev/null +++ b/lib/who_need_help_web/controllers/health_controller.ex @@ -0,0 +1,12 @@ +defmodule WhoNeedHelpWeb.HealthController do + use WhoNeedHelpWeb, :controller + + def live(conn, _params), do: json(conn, %{status: "ok", node: to_string(Node.self())}) + + def ready(conn, _params) do + case Ecto.Adapters.SQL.query(WhoNeedHelp.Repo, "SELECT 1", []) do + {:ok, _} -> json(conn, %{status: "ready", node: to_string(Node.self())}) + {:error, _} -> conn |> put_status(:service_unavailable) |> json(%{status: "not_ready"}) + end + end +end diff --git a/lib/who_need_help_web/controllers/page_controller.ex b/lib/who_need_help_web/controllers/page_controller.ex new file mode 100644 index 0000000..23c54b1 --- /dev/null +++ b/lib/who_need_help_web/controllers/page_controller.ex @@ -0,0 +1,11 @@ +defmodule WhoNeedHelpWeb.PageController do + use WhoNeedHelpWeb, :controller + + def home(conn, _params) do + render(conn, :home) + end + + def safety(conn, _params) do + render(conn, :safety) + end +end diff --git a/lib/who_need_help_web/controllers/page_html.ex b/lib/who_need_help_web/controllers/page_html.ex new file mode 100644 index 0000000..3327def --- /dev/null +++ b/lib/who_need_help_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule WhoNeedHelpWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use WhoNeedHelpWeb, :html + + embed_templates "page_html/*" +end diff --git a/lib/who_need_help_web/controllers/page_html/home.html.heex b/lib/who_need_help_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..54352f9 --- /dev/null +++ b/lib/who_need_help_web/controllers/page_html/home.html.heex @@ -0,0 +1,78 @@ + +
+
+
+ {gettext("Fast, local, voluntary help")} +
+

+ {gettext("Help can be closer than you think.")} +

+

+ Who Need Help connects people who urgently need a legal medicine picked up + with nearby volunteers who can help for free. +

+
+ <.link navigate={~p"/requests"} class="btn btn-primary btn-lg"> + {gettext("See nearby requests")} + + <%= if @current_scope do %> + <.link navigate={~p"/requests/new"} class="btn btn-outline btn-lg"> + {gettext("Ask for help")} + + <% else %> + <.link navigate={~p"/users/register"} class="btn btn-outline btn-lg"> + {gettext("Join the community")} + + <% end %> +
+

+ Not an emergency or medical service. In immediate danger, contact local emergency services. +

+
+ +
+
+
+
+ Medicine pickup + urgent +
+

Medicine order is ready at the pharmacy

+

+ “The pharmacy closes soon and I cannot leave home. The item is already paid for.” +

+
+
+
Approximate area
+
Exact address after matching
+
+ +
+
+
+
+ +
+
+
1
+

Post a clear request

+

+ Choose a category, urgency, and safe location visibility. +

+
+
+
2
+

Match and coordinate

+

+ Use private chat and optional foreground tracking. +

+
+
+
3
+

Verify the handover

+

+ Both confirm and the helper enters a one-time code. +

+
+
+
diff --git a/lib/who_need_help_web/controllers/page_html/safety.html.heex b/lib/who_need_help_web/controllers/page_html/safety.html.heex new file mode 100644 index 0000000..545e93f --- /dev/null +++ b/lib/who_need_help_web/controllers/page_html/safety.html.heex @@ -0,0 +1,65 @@ + +
+
READ BEFORE USING THE SERVICE
+

Safety rules

+

+ Who Need Help coordinates voluntary help between adults. It cannot verify every person, + request, item, route, or outcome and cannot guarantee safety. +

+ +
+
+

Not for emergencies

+

+ Do not wait for a volunteer when there is immediate danger, a serious medical situation, + fire, violence, or a crime in progress. Contact the emergency service for your location. +

+
+ +
+

Medicine requests

+

+ Only request pickup of a legal item already reserved or paid for. Do not request medical + advice, a prescription, controlled substances, cash advances, repackaging, or a purchase + on your behalf. Follow pharmacy rules and local law. +

+
+ +
+

Free help and thanks

+

+ Help is voluntary and has no required fee. An external thank-you link is optional and + appears only after completion. The platform does not receive, split, refund, guarantee, + or report that transfer. +

+
+ +
+

Location privacy

+

+ Public location can be hidden, approximate, or explicitly exact. Foreground tracking is + optional. The current exact point is deleted when sharing stops, a match ends, or either + participant blocks the other; derived movement and proximity evidence may remain. +

+
+ +
+

Identity and reputation

+

+ Email confirmation and social links are not identity guarantees. Manually attached social + links are marked unverified. Check the person's history, unique interactions, handover + evidence, and revealed reviews, and meet only where you feel safe. +

+
+ +
+

Block and report

+

+ Block a person to hide discovery in both directions and stop new chat. Report dangerous, + fraudulent, prohibited, impersonating, harassing, or spam content. Automated signals ask + for human review and are not proof by themselves. +

+
+
+
+
diff --git a/lib/who_need_help_web/controllers/user_registration_controller.ex b/lib/who_need_help_web/controllers/user_registration_controller.ex new file mode 100644 index 0000000..125f36f --- /dev/null +++ b/lib/who_need_help_web/controllers/user_registration_controller.ex @@ -0,0 +1,46 @@ +defmodule WhoNeedHelpWeb.UserRegistrationController do + use WhoNeedHelpWeb, :controller + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.User + alias WhoNeedHelp.Trust.RateLimiter + + def new(conn, _params) do + changeset = Accounts.change_user_registration(%User{}, %{}, validate_unique: false) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"user" => user_params}) do + email_scope = user_params["email"] |> to_string() |> String.trim() |> String.downcase() + + with {:ok, _limit} <- RateLimiter.check(:registration_email, email_scope), + result <- Accounts.register_user(user_params) do + case result do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + + conn + |> put_flash( + :info, + "An email was sent to #{user.email}, please access it to confirm your account." + ) + |> redirect(to: ~p"/users/log-in") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + else + {:error, :rate_limited} -> + conn + |> put_status(:too_many_requests) + |> put_flash(:error, "Too many registration attempts in the configured time window.") + |> render(:new, + changeset: Accounts.change_user_registration(%User{}, user_params) + ) + end + end +end diff --git a/lib/who_need_help_web/controllers/user_registration_html.ex b/lib/who_need_help_web/controllers/user_registration_html.ex new file mode 100644 index 0000000..0f45696 --- /dev/null +++ b/lib/who_need_help_web/controllers/user_registration_html.ex @@ -0,0 +1,5 @@ +defmodule WhoNeedHelpWeb.UserRegistrationHTML do + use WhoNeedHelpWeb, :html + + embed_templates "user_registration_html/*" +end diff --git a/lib/who_need_help_web/controllers/user_registration_html/new.html.heex b/lib/who_need_help_web/controllers/user_registration_html/new.html.heex new file mode 100644 index 0000000..d24b6ad --- /dev/null +++ b/lib/who_need_help_web/controllers/user_registration_html/new.html.heex @@ -0,0 +1,57 @@ + +
+
+ <.header> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + +
+ + <.form :let={f} for={@changeset} action={~p"/users/register"}> + <.input + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + phx-mounted={JS.focus()} + /> + + <.input + field={f[:display_name]} + type="text" + label="Display name" + autocomplete="name" + required + /> + +

+ Read the <.link href={~p"/safety"} class="link font-semibold">safety rules + before confirming. +

+ + <.input + field={f[:terms_accepted]} + type="checkbox" + label="I am 18 or older and accept the safety rules" + required + /> + +

+ Who Need Help is not an emergency or medical service. Medicine requests are limited + to pickup of legal items already purchased or reserved. +

+ + <.button phx-disable-with="Creating account..." class="btn btn-primary w-full"> + Create an account + + +
+
diff --git a/lib/who_need_help_web/controllers/user_session_controller.ex b/lib/who_need_help_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..a57a38f --- /dev/null +++ b/lib/who_need_help_web/controllers/user_session_controller.ex @@ -0,0 +1,100 @@ +defmodule WhoNeedHelpWeb.UserSessionController do + use WhoNeedHelpWeb, :controller + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Trust.RateLimiter + alias WhoNeedHelpWeb.UserAuth + + def new(conn, _params) do + email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)]) + form = Phoenix.Component.to_form(%{"email" => email}, as: "user") + + render(conn, :new, form: form) + end + + # magic link login + def create(conn, %{"user" => %{"token" => token} = user_params} = params) do + info = + case params do + %{"_action" => "confirmed"} -> "User confirmed successfully." + _ -> "Welcome back!" + end + + case Accounts.login_user_by_magic_link(token) do + {:ok, {user, _expired_tokens}} -> + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + + {:error, :not_found} -> + conn + |> put_flash(:error, "The link is invalid or it has expired.") + |> render(:new, form: Phoenix.Component.to_form(%{}, as: "user")) + end + end + + # email + password login + def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, "Welcome back!") + |> UserAuth.log_in_user(user, user_params) + else + form = Phoenix.Component.to_form(user_params, as: "user") + + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + conn + |> put_flash(:error, "Invalid email or password") + |> render(:new, form: form) + end + end + + # magic link request + def create(conn, %{"user" => %{"email" => email}}) do + email_scope = email |> String.trim() |> String.downcase() + + case RateLimiter.check(:magic_link_email, email_scope) do + {:ok, _limit} -> + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions for logging in shortly." + + conn + |> put_flash(:info, info) + |> redirect(to: ~p"/users/log-in") + + {:error, :rate_limited} -> + conn + |> put_status(:too_many_requests) + |> put_flash(:error, "Too many sign-in emails in the configured time window.") + |> render(:new, form: Phoenix.Component.to_form(%{"email" => email}, as: "user")) + end + end + + def confirm(conn, %{"token" => token}) do + if user = Accounts.get_user_by_magic_link_token(token) do + form = Phoenix.Component.to_form(%{"token" => token}, as: "user") + + conn + |> assign(:user, user) + |> assign(:form, form) + |> render(:confirm) + else + conn + |> put_flash(:error, "Magic link is invalid or it has expired.") + |> redirect(to: ~p"/users/log-in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/who_need_help_web/controllers/user_session_html.ex b/lib/who_need_help_web/controllers/user_session_html.ex new file mode 100644 index 0000000..e144a9f --- /dev/null +++ b/lib/who_need_help_web/controllers/user_session_html.ex @@ -0,0 +1,9 @@ +defmodule WhoNeedHelpWeb.UserSessionHTML do + use WhoNeedHelpWeb, :html + + embed_templates "user_session_html/*" + + defp local_mail_adapter? do + Application.get_env(:who_need_help, WhoNeedHelp.Mailer)[:adapter] == Swoosh.Adapters.Local + end +end diff --git a/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex b/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex new file mode 100644 index 0000000..7118b86 --- /dev/null +++ b/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex @@ -0,0 +1,59 @@ + +
+
+ <.header>Welcome {@user.email} +
+ + <.form + :if={!@user.confirmed_at} + for={@form} + id="confirmation_form" + action={~p"/users/log-in?_action=confirmed"} + phx-mounted={JS.focus_first()} + > + + <.button + name={@form[:remember_me].name} + value="true" + phx-disable-with="Confirming..." + class="btn btn-primary w-full" + > + Confirm and stay logged in + + <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2"> + Confirm and log in only this time + + + + <.form + :if={@user.confirmed_at} + for={@form} + id="login_form" + action={~p"/users/log-in"} + phx-mounted={JS.focus_first()} + > + + <%= if @current_scope do %> + <.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full"> + Log in + + <% else %> + <.button + name={@form[:remember_me].name} + value="true" + phx-disable-with="Logging in..." + class="btn btn-primary w-full" + > + Keep me logged in on this device + + <.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2"> + Log me in only this time + + <% end %> + + +

+ Tip: If you prefer passwords, you can enable them in the user settings. +

+
+
diff --git a/lib/who_need_help_web/controllers/user_session_html/new.html.heex b/lib/who_need_help_web/controllers/user_session_html/new.html.heex new file mode 100644 index 0000000..2df8002 --- /dev/null +++ b/lib/who_need_help_web/controllers/user_session_html/new.html.heex @@ -0,0 +1,73 @@ + +
+
+ <.header> +

Log in

+ <:subtitle> + <%= if @current_scope do %> + You need to reauthenticate to perform sensitive actions on your account. + <% else %> + Don't have an account? <.link + navigate={~p"/users/register"} + class="font-semibold text-brand hover:underline" + phx-no-format + >Sign up for an account now. + <% end %> + + +
+ +
+ <.icon name="hero-information-circle" class="size-6 shrink-0" /> +
+

You are running the local mail adapter.

+

+ To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. +

+
+
+ + <.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}> + <.input + readonly={!!@current_scope} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + phx-mounted={JS.focus()} + /> + <.button class="btn btn-primary w-full"> + Log in with email + + + +
or
+ + <.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}> + <.input + readonly={!!@current_scope} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + /> + <.input + field={f[:password]} + type="password" + label="Password" + autocomplete="current-password" + spellcheck="false" + /> + <.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true"> + Log in and stay logged in + + <.button class="btn btn-primary btn-soft w-full mt-2"> + Log in only this time + + +
+
diff --git a/lib/who_need_help_web/controllers/user_settings_controller.ex b/lib/who_need_help_web/controllers/user_settings_controller.ex new file mode 100644 index 0000000..92ca246 --- /dev/null +++ b/lib/who_need_help_web/controllers/user_settings_controller.ex @@ -0,0 +1,77 @@ +defmodule WhoNeedHelpWeb.UserSettingsController do + use WhoNeedHelpWeb, :controller + + alias WhoNeedHelp.Accounts + alias WhoNeedHelpWeb.UserAuth + + import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2] + + plug :require_sudo_mode + plug :assign_email_and_password_changesets + + def edit(conn, _params) do + render(conn, :edit) + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"user" => user_params} = params + user = conn.assigns.current_scope.user + + case Accounts.change_user_email(user, user_params) do + %{valid?: true} = changeset -> + Accounts.deliver_user_update_email_instructions( + Ecto.Changeset.apply_action!(changeset, :insert), + user.email, + &url(~p"/users/settings/confirm-email/#{&1}") + ) + + conn + |> put_flash( + :info, + "A link to confirm your email change has been sent to the new address." + ) + |> redirect(to: ~p"/users/settings") + + changeset -> + render(conn, :edit, email_changeset: %{changeset | action: :insert}) + end + end + + def update(conn, %{"action" => "update_password"} = params) do + %{"user" => user_params} = params + user = conn.assigns.current_scope.user + + case Accounts.update_user_password(user, user_params) do + {:ok, {user, _}} -> + conn + |> put_flash(:info, "Password updated successfully.") + |> put_session(:user_return_to, ~p"/users/settings") + |> UserAuth.log_in_user(user) + + {:error, changeset} -> + render(conn, :edit, password_changeset: changeset) + end + end + + def confirm_email(conn, %{"token" => token}) do + case Accounts.update_user_email(conn.assigns.current_scope.user, token) do + {:ok, _user} -> + conn + |> put_flash(:info, "Email changed successfully.") + |> redirect(to: ~p"/users/settings") + + {:error, _} -> + conn + |> put_flash(:error, "Email change link is invalid or it has expired.") + |> redirect(to: ~p"/users/settings") + end + end + + defp assign_email_and_password_changesets(conn, _opts) do + user = conn.assigns.current_scope.user + + conn + |> assign(:email_changeset, Accounts.change_user_email(user)) + |> assign(:password_changeset, Accounts.change_user_password(user)) + end +end diff --git a/lib/who_need_help_web/controllers/user_settings_html.ex b/lib/who_need_help_web/controllers/user_settings_html.ex new file mode 100644 index 0000000..46fd23e --- /dev/null +++ b/lib/who_need_help_web/controllers/user_settings_html.ex @@ -0,0 +1,5 @@ +defmodule WhoNeedHelpWeb.UserSettingsHTML do + use WhoNeedHelpWeb, :html + + embed_templates "user_settings_html/*" +end diff --git a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex new file mode 100644 index 0000000..6306e80 --- /dev/null +++ b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex @@ -0,0 +1,49 @@ + +
+ <.header> + Account Settings + <:subtitle>Manage your account email address and password settings + +
+ + <.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email"> + + + <.input + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + /> + + <.button variant="primary" phx-disable-with="Changing...">Change Email + + +
+ + <.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password"> + + + <.input + field={f[:password]} + type="password" + label="New password" + autocomplete="new-password" + spellcheck="false" + required + /> + <.input + field={f[:password_confirmation]} + type="password" + label="Confirm new password" + autocomplete="new-password" + spellcheck="false" + required + /> + <.button variant="primary" phx-disable-with="Changing..."> + Save Password + + + diff --git a/lib/who_need_help_web/endpoint.ex b/lib/who_need_help_web/endpoint.ex new file mode 100644 index 0000000..fdcaaaf --- /dev/null +++ b/lib/who_need_help_web/endpoint.ex @@ -0,0 +1,55 @@ +defmodule WhoNeedHelpWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :who_need_help + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_who_need_help_key", + signing_salt: "XN+IwK3w", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. + plug Plug.Static, + at: "/", + from: :who_need_help, + gzip: not code_reloading?, + only: WhoNeedHelpWeb.static_paths(), + raise_on_missing_only: code_reloading? + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :who_need_help + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug WhoNeedHelpWeb.Router +end diff --git a/lib/who_need_help_web/gettext.ex b/lib/who_need_help_web/gettext.ex new file mode 100644 index 0000000..72d1849 --- /dev/null +++ b/lib/who_need_help_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule WhoNeedHelpWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://gettext.hexdocs.pm), your module compiles translations + that you can use in your application. To use this Gettext backend module, + call `use Gettext` and pass it as an option: + + use Gettext, backend: WhoNeedHelpWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://gettext.hexdocs.pm) for detailed usage. + """ + use Gettext.Backend, otp_app: :who_need_help +end diff --git a/lib/who_need_help_web/live/category_proposal_live.ex b/lib/who_need_help_web/live/category_proposal_live.ex new file mode 100644 index 0000000..22497ea --- /dev/null +++ b/lib/who_need_help_web/live/category_proposal_live.ex @@ -0,0 +1,111 @@ +defmodule WhoNeedHelpWeb.CategoryProposalLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.Catalog + alias WhoNeedHelp.Catalog.CategoryProposal + + @impl true + def mount(_params, _session, socket) do + {:ok, load(socket)} + end + + @impl true + def handle_event("propose", %{"category_proposal" => params}, socket) do + case Catalog.propose(socket.assigns.current_scope, params) do + {:ok, _} -> + {:noreply, + socket |> put_flash(:info, "Proposal published for community voting.") |> load()} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, action_error(reason))} + end + end + + def handle_event("vote", %{"id" => id}, socket) do + case Catalog.vote(socket.assigns.current_scope, id) do + {:ok, _} -> {:noreply, load(socket)} + {:error, reason} -> {:noreply, put_flash(socket, :error, action_error(reason))} + end + end + + defp action_error(:account_not_eligible), + do: "Confirm your account and ensure it is active first." + + defp action_error(:rate_limited), do: "Too many actions in the configured time window." + defp action_error(:proposal_closed), do: "This proposal is already closed." + defp action_error(%Ecto.Changeset{}), do: "You already voted for this proposal." + defp action_error(reason), do: "Could not complete the action: #{inspect(reason)}" + + defp load(socket) do + socket + |> assign(:page_title, "Category proposals") + |> assign(:proposals, Catalog.list_proposals()) + |> assign(:categories, Catalog.list_categories()) + |> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{}))) + end + + @impl true + def render(assigns) do + ~H""" + +
+
+
COMMUNITY-DRIVEN TAXONOMY
+

What help category is missing?

+

+ Proposals and votes guide moderators. Approval remains a human decision. +

+ <.form for={@form} phx-submit="propose" class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6"> + <.input + field={@form[:proposed_name]} + label="Proposed category" + placeholder="Bicycle puncture" + /> + <.input + field={@form[:parent_id]} + type="select" + label="Parent (optional)" + prompt="Top level" + options={ + Enum.map( + @categories, + &{WhoNeedHelp.Catalog.Category.name(&1, @current_scope.user.locale), &1.id} + ) + } + /> + <.input field={@form[:reason]} type="textarea" label="Why is this useful?" /> + <.button class="btn btn-primary w-full">Publish proposal + +
+ +
+

Open proposals

+
+
+ No proposals yet. +
+
+
+
+

{proposal.proposed_name}

+

{proposal.reason}

+

by {proposal.proposer.display_name}

+
+ +
+
+
+
+
+
+ """ + end +end diff --git a/lib/who_need_help_web/live/leaderboard_live.ex b/lib/who_need_help_web/live/leaderboard_live.ex new file mode 100644 index 0000000..65d5c47 --- /dev/null +++ b/lib/who_need_help_web/live/leaderboard_live.ex @@ -0,0 +1,63 @@ +defmodule WhoNeedHelpWeb.LeaderboardLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.Trust + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Community helpers") + |> assign(:leaders, Trust.leaderboard())} + end + + @impl true + def render(assigns) do + ~H""" + + <.link navigate={~p"/requests"} class="btn btn-ghost btn-sm">← Requests +
+
COMMUNITY TRUST
+

Helpers leaderboard

+

+ Ranking prioritizes unique people helped with optional location-supported evidence, + then unique verified handovers. Repeated help between the same pair does not inflate + the primary score. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
RankHelperLocation-supported peopleVerified peopleUnique peopleTotalRating
+ No completed help yet. +
#{index}{entry.user.display_name}{entry.location_supported_people}{entry.verified_people}{entry.unique_people}{entry.completed}{entry.rating || "—"}
+
+
+
+ """ + end +end diff --git a/lib/who_need_help_web/live/moderation_live.ex b/lib/who_need_help_web/live/moderation_live.ex new file mode 100644 index 0000000..5c9982e --- /dev/null +++ b/lib/who_need_help_web/live/moderation_live.ex @@ -0,0 +1,411 @@ +defmodule WhoNeedHelpWeb.ModerationLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.{Accounts, Catalog, Trust} + + @impl true + def mount(_params, _session, socket) do + {:ok, socket |> assign(:page_title, "Moderation") |> assign(:evidence, nil) |> load()} + end + + @impl true + def handle_event("moderate-report", %{"id" => id, "moderation" => params}, socket) do + respond( + socket, + Trust.moderate_report(socket.assigns.current_scope, id, params), + "Report updated." + ) + end + + def handle_event("view-evidence", %{"id" => id}, socket) do + case Trust.report_evidence(socket.assigns.current_scope, id) do + {:ok, evidence} -> {:noreply, assign(socket, :evidence, evidence)} + {:error, reason} -> {:noreply, put_flash(socket, :error, error_message(reason))} + end + end + + def handle_event("close-evidence", _, socket), do: {:noreply, assign(socket, :evidence, nil)} + + def handle_event("moderate-signal", %{"id" => id, "moderation" => params}, socket) do + respond( + socket, + Trust.moderate_signal(socket.assigns.current_scope, id, params), + "Signal updated." + ) + end + + def handle_event("moderate-user", %{"id" => id, "moderation" => params}, socket) do + respond( + socket, + Trust.moderate_user(socket.assigns.current_scope, id, params), + "User status updated." + ) + end + + def handle_event("moderate-role", %{"id" => id, "moderation" => params}, socket) do + respond( + socket, + Trust.moderate_role(socket.assigns.current_scope, id, params), + "User role updated." + ) + end + + def handle_event("hide-request", %{"id" => id, "moderation" => %{"note" => note}}, socket) do + respond( + socket, + Trust.hide_request(socket.assigns.current_scope, id, note), + "Request hidden." + ) + end + + def handle_event("restore-request", %{"id" => id}, socket) do + respond( + socket, + Trust.restore_request(socket.assigns.current_scope, id), + "Request restored." + ) + end + + def handle_event("approve-proposal", %{"id" => id, "category" => params}, socket) do + names = + %{ + "en" => params["name_en"], + "uk" => params["name_uk"], + "ru" => params["name_ru"] + } + |> Map.reject(fn {_locale, name} -> name in [nil, ""] end) + + with {:ok, structured_fields} <- Jason.decode(params["structured_fields_json"] || "{}"), + true <- is_map(structured_fields) do + attrs = %{ + "slug" => params["slug"], + "names" => names, + "description" => params["description"], + "structured_fields" => structured_fields, + "moderation_note" => params["moderation_note"] + } + + respond( + socket, + Catalog.approve_proposal(socket.assigns.current_scope, id, attrs), + "Category created and proposal approved." + ) + else + _ -> + {:noreply, put_flash(socket, :error, "Structured fields must be a valid JSON object.")} + end + end + + def handle_event("reject-proposal", %{"id" => id, "moderation" => %{"note" => note}}, socket) do + respond( + socket, + Catalog.reject_proposal(socket.assigns.current_scope, id, note), + "Proposal rejected." + ) + end + + def handle_event( + "merge-proposal", + %{"id" => id, "moderation" => %{"category_id" => category_id, "note" => note}}, + socket + ) do + respond( + socket, + Catalog.merge_proposal(socket.assigns.current_scope, id, category_id, note), + "Proposal merged." + ) + end + + defp respond(socket, {:ok, _value}, message) do + {:noreply, socket |> put_flash(:info, message) |> load()} + end + + defp respond(socket, {:error, reason}, _message) do + {:noreply, put_flash(socket, :error, error_message(reason))} + end + + defp load(socket) do + socket + |> assign(:reports, Trust.list_reports(socket.assigns.current_scope)) + |> assign(:signals, Trust.list_abuse_signals(socket.assigns.current_scope)) + |> assign(:proposals, Catalog.list_proposals_for_moderation(socket.assigns.current_scope)) + |> assign(:users, Accounts.list_users_for_moderation(socket.assigns.current_scope)) + |> assign(:categories, Catalog.list_categories()) + end + + defp error_message(:forbidden), do: "Moderator access is required." + defp error_message(:proposal_closed), do: "This proposal has already been reviewed." + defp error_message(:cannot_restrict_self), do: "You cannot restrict your own moderator account." + defp error_message(:last_admin), do: "The last administrator cannot demote themselves." + defp error_message(%Ecto.Changeset{}), do: "Please check the submitted fields." + defp error_message(reason), do: "Could not complete moderation: #{inspect(reason)}" + + defp scoped_form(data, as, scope) do + to_form(data, as: as, id: "#{as}-#{scope}") + end + + @impl true + def render(assigns) do + ~H""" + +
RESTRICTED WORKSPACE
+

Moderation

+

+ Decisions and access to reported chat evidence are written to the audit log. +

+ +
+

Reports

+
+
+ <% hide_form = scoped_form(%{"note" => ""}, :moderation, "report-hide-#{report.id}") %> + <% report_form = + scoped_form( + %{"status" => to_string(report.status), "resolution_note" => ""}, + :moderation, + "report-review-#{report.id}" + ) %> +
+ {report.reason} + {report.status} + by {report.reporter.display_name} +
+

{report.details}

+
+ + <.form + :if={report.request_id} + for={hide_form} + phx-submit="hide-request" + phx-value-id={report.request_id} + class="flex gap-2" + > + <.input field={hide_form[:note]} placeholder="Hide reason" /> + <.button class="btn btn-sm btn-error self-end">Hide request + +
+ <.form + for={report_form} + phx-submit="moderate-report" + phx-value-id={report.id} + class="mt-4 grid gap-2 md:grid-cols-[12rem_1fr_auto]" + > + <.input + field={report_form[:status]} + type="select" + options={["reviewing", "resolved", "dismissed"]} + /> + <.input field={report_form[:resolution_note]} placeholder="Resolution note" /> + <.button class="btn btn-primary self-end">Save + +
+

No reports.

+
+
+ +
+
+

Reported evidence

+ +
+

+ Only the conversation linked to this report is displayed. +

+
+
+ {message.sender.display_name}: {message.body} +
+

+ This report has no linked conversation. +

+
+
+ +
+

Open abuse signals

+
+
+ <% signal_form = + scoped_form( + %{"status" => "reviewed", "review_note" => ""}, + :moderation, + "signal-#{signal.id}" + ) %> +
{signal.kind} · {signal.subject.display_name}
+

+ A signal requests human review; optional tracking absence is not proof of abuse. +

+ <.form + for={signal_form} + phx-submit="moderate-signal" + phx-value-id={signal.id} + class="mt-3 grid gap-2 md:grid-cols-[12rem_1fr_auto]" + > + <.input + field={signal_form[:status]} + type="select" + options={["reviewed", "dismissed"]} + /> + <.input field={signal_form[:review_note]} placeholder="Review note" /> + <.button class="btn btn-sm btn-primary self-end">Save + +
+

No open signals.

+
+
+ +
+

Category proposals

+
+
+ <% category_form = + scoped_form( + %{"structured_fields_json" => ~s({"fields":[]})}, + :category, + "approve-#{proposal.id}" + ) %> + <% reject_form = + scoped_form(%{"note" => ""}, :moderation, "reject-#{proposal.id}") %> + <% merge_form = + scoped_form(%{}, :moderation, "merge-#{proposal.id}") %> +

+ {proposal.proposed_name} {proposal.status} +

+

{proposal.reason}

+

{length(proposal.votes)} community votes

+
+ <.form + for={category_form} + phx-submit="approve-proposal" + phx-value-id={proposal.id} + class="space-y-2 rounded-xl bg-base-200 p-4" + > + <.input field={category_form[:slug]} label="Slug" /> + <.input field={category_form[:name_en]} label="English name" /> + <.input + field={category_form[:name_uk]} + label="Ukrainian name (optional)" + /> + <.input field={category_form[:name_ru]} label="Russian name (optional)" /> + <.input field={category_form[:description]} label="Description" /> + <.input + field={category_form[:structured_fields_json]} + type="textarea" + label="Structured fields JSON" + /> + <.input field={category_form[:moderation_note]} label="Decision note" /> + <.button class="btn btn-success w-full">Create category + +
+ <.form + for={reject_form} + phx-submit="reject-proposal" + phx-value-id={proposal.id} + class="flex gap-2" + > + <.input field={reject_form[:note]} placeholder="Rejection note" /> + <.button class="btn btn-error self-end">Reject + + <.form + for={merge_form} + phx-submit="merge-proposal" + phx-value-id={proposal.id} + class="space-y-2" + > + <.input + field={merge_form[:category_id]} + type="select" + label="Merge into" + options={ + Enum.map(@categories, &{WhoNeedHelp.Catalog.Category.name(&1, "en"), &1.id}) + } + /> + <.input field={merge_form[:note]} placeholder="Merge note" /> + <.button class="btn btn-outline w-full">Merge + +
+
+
+
+
+ +
+

Accounts

+
+ + + + + + + + + <% user_form = + scoped_form( + %{ + "moderation_status" => to_string(user.moderation_status), + "moderation_note" => user.moderation_note || "" + }, + :moderation, + "user-status-#{user.id}" + ) %> + <% role_form = + scoped_form( + %{"role" => to_string(user.role)}, + :moderation, + "user-role-#{user.id}" + ) %> + + + + + + +
UserRoleStatusDecision
+ {user.display_name} +
{user.email}
+
{user.role}{user.moderation_status} + <.form + for={user_form} + phx-submit="moderate-user" + phx-value-id={user.id} + class="flex min-w-[28rem] gap-2" + > + <.input + field={user_form[:moderation_status]} + type="select" + options={["active", "restricted", "suspended"]} + /> + <.input + field={user_form[:moderation_note]} + placeholder="Internal note" + /> + <.button class="btn btn-sm btn-primary self-end">Save + + <.form + :if={@current_scope.user.role == :admin} + for={role_form} + phx-submit="moderate-role" + phx-value-id={user.id} + class="mt-2 flex gap-2" + > + <.input + field={role_form[:role]} + type="select" + options={["user", "moderator", "admin"]} + /> + <.button class="btn btn-sm btn-warning self-end">Change role + +
+
+
+
+ """ + end +end diff --git a/lib/who_need_help_web/live/profile_live.ex b/lib/who_need_help_web/live/profile_live.ex new file mode 100644 index 0000000..ac18529 --- /dev/null +++ b/lib/who_need_help_web/live/profile_live.ex @@ -0,0 +1,266 @@ +defmodule WhoNeedHelpWeb.ProfileLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Trust + + @impl true + def mount(_params, _session, socket) do + user = socket.assigns.current_scope.user + + {:ok, + socket + |> assign(:page_title, "Profile") + |> assign(:reputation, Trust.reputation(user.id)) + |> assign(:reviews, Trust.visible_reviews(user.id)) + |> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope)) + |> assign(:form, to_form(Accounts.change_user_profile(user))) + |> assign_social_identities(user)} + end + + @impl true + def handle_event("save", %{"user" => params}, socket) do + case Accounts.update_user_profile(socket.assigns.current_scope.user, params) do + {:ok, user} -> + {:noreply, + socket + |> assign(:current_scope, WhoNeedHelp.Accounts.Scope.for_user(user)) + |> assign(:form, to_form(Accounts.change_user_profile(user))) + |> put_flash(:info, "Profile updated.")} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + end + + def handle_event("add-social", %{"social_identity" => params}, socket) do + user = socket.assigns.current_scope.user + + case Accounts.add_social_identity(user, params) do + {:ok, _identity} -> + {:noreply, + socket + |> assign_social_identities(user) + |> put_flash(:info, "Social link added as unverified.")} + + {:error, changeset} -> + {:noreply, assign(socket, :social_form, to_form(changeset, as: :social_identity))} + end + end + + def handle_event("remove-social", %{"id" => identity_id}, socket) do + user = socket.assigns.current_scope.user + + case Accounts.delete_social_identity(user, identity_id) do + {:ok, _identity} -> + {:noreply, assign_social_identities(socket, user)} + + {:error, :not_found} -> + {:noreply, put_flash(socket, :error, "Social link was not found.")} + end + end + + def handle_event("unblock", %{"id" => blocked_id}, socket) do + case Trust.unblock(socket.assigns.current_scope, blocked_id) do + {:ok, _result} -> + {:noreply, + socket + |> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope)) + |> put_flash(:info, "User unblocked.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Could not unblock: #{inspect(reason)}")} + end + end + + defp assign_social_identities(socket, user) do + socket + |> assign(:social_identities, Accounts.list_social_identities(user)) + |> assign( + :social_form, + to_form( + Accounts.change_social_identity(%WhoNeedHelp.Accounts.SocialIdentity{}), + as: :social_identity + ) + ) + end + + @impl true + def render(assigns) do + ~H""" + +
+

Your profile

+
+ <.form + for={@form} + phx-submit="save" + class="space-y-4 rounded-3xl border border-base-300 p-6" + > + <.input field={@form[:display_name]} label="Display name" /> + <.input field={@form[:bio]} type="textarea" label="Short bio" /> + <.input + field={@form[:locale]} + type="select" + label="Interface language" + options={[{"English", "en"}, {"Українська", "uk"}, {"Русский", "ru"}]} + /> + <.input + field={@form[:location_visibility]} + type="select" + label="Default location visibility" + options={[ + {"Hidden", "hidden"}, + {"Approximate publicly", "approximate_public"}, + {"Exact for active match", "exact_for_active_match"}, + {"Exact publicly", "exact_public"} + ]} + /> + <.input + field={@form[:tip_url]} + label="Optional external thank-you link" + placeholder="https://…" + /> +

+ The platform never handles, requires, splits, or guarantees a payment. +

+ <.button class="btn btn-primary w-full">Save profile + + + +
+ +
+
+
+

Social links

+

+ Manually added links are public and marked unverified. OAuth verification is not + enabled in this MVP. +

+
+
+ +
+
+

+ No social links added. +

+
+
+
+ {identity.provider} + + {if identity.verified_at, do: "verified", else: "unverified"} + +
+ + {identity.handle || identity.profile_url} + +
+ +
+
+ + <.form for={@social_form} phx-submit="add-social" class="space-y-3"> + <.input + field={@social_form[:provider]} + type="select" + label="Network" + options={[ + {"Instagram", "instagram"}, + {"Facebook", "facebook"}, + {"Telegram", "telegram"}, + {"Google", "google"}, + {"Other", "other"} + ]} + /> + <.input + field={@social_form[:profile_url]} + type="url" + label="Public profile URL" + placeholder="https://…" + /> + <.input field={@social_form[:handle]} label="Handle (optional)" placeholder="@name" /> + <.button class="btn btn-outline w-full">Add unverified link + +
+
+ +
+

Blocked users

+

+ You have not blocked anyone. +

+
+ {block.blocked.display_name} + +
+
+
+
+ """ + end +end diff --git a/lib/who_need_help_web/live/request_live/index.ex b/lib/who_need_help_web/live/request_live/index.ex new file mode 100644 index 0000000..2427a50 --- /dev/null +++ b/lib/who_need_help_web/live/request_live/index.ex @@ -0,0 +1,188 @@ +defmodule WhoNeedHelpWeb.RequestLive.Index do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.{Help, Trust} + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: Help.subscribe() + + {:ok, + socket + |> assign(:filters, %{"category_id" => "", "urgency" => ""}) + |> load()} + end + + @impl true + def handle_info({event, _request}, socket) when event in [:request_created, :request_updated] do + {:noreply, load(socket)} + end + + @impl true + def handle_event("filter", %{"filters" => filters}, socket) do + {:noreply, socket |> assign(:filters, filters) |> load()} + end + + defp load(socket) do + requests = Help.list_open_requests(socket.assigns.current_scope, socket.assigns.filters) + user = socket.assigns.current_scope.user + + socket + |> assign(:page_title, "Nearby help") + |> assign(:requests, requests) + |> assign(:my_requests, Help.list_my_requests(socket.assigns.current_scope)) + |> assign(:reputation, Trust.reputation(user.id)) + |> assign(:categories, WhoNeedHelp.Catalog.list_categories()) + |> assign(:filter_form, to_form(socket.assigns.filters, as: :filters)) + |> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1))))) + end + + defp marker(request) do + case Help.HelpRequest.public_coordinates(request) do + nil -> + nil + + coordinates -> + Map.merge(coordinates, %{ + id: request.id, + title: request.title, + location: request.location_label + }) + end + end + + @impl true + def render(assigns) do + ~H""" + +
+
+
NEARBY MUTUAL AID
+

Who needs help?

+

+ Only approximate areas are shown before a safe match. +

+
+ <.link navigate={~p"/requests/new"} class="btn btn-primary">Create urgent request +
+ +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + This is not an emergency service. Contact local emergency services for immediate danger. +
+ + <.form + for={@filter_form} + phx-change="filter" + class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 sm:grid-cols-2" + > + <.input + field={@filter_form[:category_id]} + type="select" + label="Category" + options={[ + {"All categories", ""} + | Enum.map( + @categories, + &{WhoNeedHelp.Catalog.Category.name(&1, @current_scope.user.locale), &1.id} + ) + ]} + /> + <.input + field={@filter_form[:urgency]} + type="select" + label="Urgency" + options={[ + {"Any urgency", ""}, + {"Now", "now"}, + {"Today", "today"}, + {"Scheduled", "scheduled"} + ]} + /> + + +
+
+ <%= if @requests == [] do %> +
+

No open requests yet

+

+ You can be the first person to ask the community. +

+
+ <% end %> + + <.link + :for={request <- @requests} + navigate={~p"/requests/#{request.id}"} + class="help-card block rounded-3xl border border-base-300 bg-base-100 p-6" + > +
+
+
+ + {WhoNeedHelp.Catalog.Category.name(request.category, @current_scope.user.locale)} + + {request.urgency} +
+

{request.title}

+

{request.description}

+
+ <.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" /> +
+
+ 📍 {request.location_label} + by {request.requester.display_name || "Community member"} + expires {Calendar.strftime(request.expires_at, "%d %b, %H:%M UTC")} +
+ +
+ + +
+ +
+

Your requests

+
+ <.link + :for={request <- @my_requests} + navigate={~p"/requests/#{request.id}"} + class="rounded-2xl bg-base-200 p-4" + > + {request.status} +
{request.title}
+ +
+
+
+ """ + end +end diff --git a/lib/who_need_help_web/live/request_live/new.ex b/lib/who_need_help_web/live/request_live/new.ex new file mode 100644 index 0000000..48d16d8 --- /dev/null +++ b/lib/who_need_help_web/live/request_live/new.ex @@ -0,0 +1,285 @@ +defmodule WhoNeedHelpWeb.RequestLive.New do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.Catalog + alias WhoNeedHelp.Help + alias WhoNeedHelp.Help.HelpRequest + + @impl true + def mount(_params, _session, socket) do + expires = DateTime.utc_now(:second) |> DateTime.add(3, :hour) + default_location_visibility = socket.assigns.current_scope.user.location_visibility + + changeset = + Help.change_request(%HelpRequest{}, %{ + "expires_at" => expires, + "urgency" => "now", + "location_visibility" => to_string(default_location_visibility) + }) + + {:ok, + socket + |> assign(:page_title, "Ask for help") + |> assign(:categories, Catalog.list_categories()) + |> assign(:selected_category, nil) + |> assign(:structured_fields, []) + |> assign(:form, to_form(changeset))} + end + + @impl true + def handle_event("validate", %{"help_request" => params}, socket) do + changeset = + %HelpRequest{} + |> Help.change_request(normalize_expiry(params)) + |> Map.put(:action, :validate) + + {:noreply, + socket + |> assign(:form, to_form(changeset)) + |> assign_category(params)} + end + + def handle_event("save", %{"help_request" => params}, socket) do + case Help.create_request(socket.assigns.current_scope, normalize_expiry(params)) do + {:ok, request} -> + {:noreply, + socket + |> put_flash(:info, "Your request is now visible to nearby helpers.") + |> push_navigate(to: ~p"/requests/#{request.id}")} + + {:error, changeset} -> + if match?(%Ecto.Changeset{}, changeset) do + {:noreply, + socket + |> assign(:form, to_form(changeset)) + |> assign_category(params)} + else + {:noreply, put_flash(socket, :error, error_message(changeset))} + end + end + end + + defp normalize_expiry(%{"expires_at" => value} = params) when is_binary(value) do + parsed = + case NaiveDateTime.from_iso8601( + value <> if(String.length(value) == 16, do: ":00", else: "") + ) do + {:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC") + _ -> value + end + + Map.put(params, "expires_at", parsed) + end + + defp normalize_expiry(params), do: params + + defp assign_category(socket, params) do + category = + Enum.find(socket.assigns.categories, &(&1.id == params["category_id"])) + + socket + |> assign(:selected_category, category) + |> assign(:structured_fields, if(category, do: Catalog.structured_fields(category), else: [])) + end + + defp field_label(field, locale) do + labels = field["label"] || %{} + labels[to_string(locale)] || labels["en"] || field["key"] + end + + defp option_label(option, locale) when is_map(option) do + labels = option["label"] || %{} + labels[to_string(locale)] || labels["en"] || option["value"] + end + + defp option_label(option, _locale), do: to_string(option) + + defp option_value(option) when is_map(option), do: option["value"] + defp option_value(option), do: option + + defp structured_value(form, key) do + form.params |> Map.get("structured_data", %{}) |> Map.get(key) + end + + defp error_message(:account_not_eligible), + do: "Confirm your email and ensure your account is active before publishing." + + defp error_message(:rate_limited), do: "Too many requests in the configured time window." + defp error_message(reason), do: "Could not publish the request: #{inspect(reason)}" + + @impl true + def render(assigns) do + ~H""" + +
+ <.link navigate={~p"/requests"} class="btn btn-ghost btn-sm mb-4">← Back to requests +

Ask for urgent help

+

+ The service is medicine-first. Moderated categories can add their own structured details + without changing this form's code. +

+ +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + + This is not an emergency service. Contact local emergency services for immediate danger. + Never include prescription details, payment card data, access codes, or other sensitive information. + +
+ + <.form + for={@form} + id="request-form" + phx-change="validate" + phx-submit="save" + class="mt-8 space-y-5" + > + <.input + field={@form[:category_id]} + type="select" + label="Category" + prompt="Choose category" + options={ + Enum.map( + @categories, + &{WhoNeedHelp.Catalog.Category.name(&1, @current_scope.user.locale), &1.id} + ) + } + /> +
+

Category details

+
+ +
+

+ {message} +

+
+ <.input + field={@form[:title]} + label="Short title" + placeholder="Medicine is ready at the pharmacy" + /> + <.input + field={@form[:description]} + type="textarea" + label="What help do you need?" + placeholder="Explain what is already arranged and what the volunteer needs to do." + /> + <.input + field={@form[:pickup_instructions]} + type="textarea" + label="Pickup instructions (optional)" + placeholder="Share only non-sensitive instructions. Exact details can wait for the matched chat." + /> +
+ <.input + field={@form[:urgency]} + type="select" + label="Urgency" + options={[{"Now", "now"}, {"Today", "today"}, {"Scheduled", "scheduled"}]} + /> + <.input field={@form[:expires_at]} type="datetime-local" label="Request expires (UTC)" /> +
+ <.input + field={@form[:location_label]} + label="Approximate area" + placeholder="Podil, near the central pharmacy" + /> + <.input + field={@form[:location_visibility]} + type="select" + label="Location visibility" + options={[ + {"Approximate publicly (recommended)", "approximate_public"}, + {"Hidden", "hidden"}, + {"Exact only for active match", "exact_for_active_match"}, + {"Exact publicly — explicit consent", "exact_public"} + ]} + /> +
+ +

+ You can also enter coordinates manually for local testing. +

+
+ <.input + field={@form[:latitude]} + id="request-latitude" + type="number" + step="any" + label="Latitude" + /> + <.input + field={@form[:longitude]} + id="request-longitude" + type="number" + step="any" + label="Longitude" + /> +
+
+ + <.button class="btn btn-primary btn-lg w-full" phx-disable-with="Publishing…">Publish request + +
+
+ """ + end +end diff --git a/lib/who_need_help_web/live/request_live/show.ex b/lib/who_need_help_web/live/request_live/show.ex new file mode 100644 index 0000000..58d0379 --- /dev/null +++ b/lib/who_need_help_web/live/request_live/show.ex @@ -0,0 +1,784 @@ +defmodule WhoNeedHelpWeb.RequestLive.Show do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.{Help, Messaging, Tracking, Trust} + + @impl true + def mount(%{"id" => id}, _session, socket) do + case Help.get_request(socket.assigns.current_scope, id) do + {:ok, request} -> + socket = assign(socket, :subscribed_assignment_id, nil) + + if connected?(socket), do: Help.subscribe_request(id) + + socket = maybe_subscribe_assignment(socket, request) + + {:ok, load(socket, request, false)} + + {:error, :not_found} -> + {:ok, + socket + |> put_flash(:error, "This request is unavailable.") + |> push_navigate(to: ~p"/requests")} + end + end + + @impl true + def handle_info({event, request}, socket) when event in [:request_updated, :request_created] do + case Help.get_request(socket.assigns.current_scope, request.id) do + {:ok, request} -> + socket = maybe_subscribe_assignment(socket, request) + {:noreply, load(socket, request, socket.assigns.tracking_active)} + + {:error, :not_found} -> + {:noreply, + socket + |> put_flash(:error, "This request is no longer available.") + |> push_navigate(to: ~p"/requests")} + end + end + + def handle_info({:new_message, _message}, socket) do + {:noreply, + load(socket, Help.get_request!(socket.assigns.request.id), socket.assigns.tracking_active)} + end + + def handle_info({:position_updated, user_id, position}, socket) do + positions = Map.put(socket.assigns.positions, user_id, position) + {:noreply, assign_positions(socket, positions)} + end + + def handle_info({:tracking_stopped, user_id}, socket) do + positions = Map.delete(socket.assigns.positions, user_id) + {:noreply, assign_positions(socket, positions)} + end + + @impl true + def handle_event("accept", _, socket) do + case Help.accept_request(socket.assigns.current_scope, socket.assigns.request.id) do + {:ok, _} -> + {:noreply, put_flash(socket, :info, "You are matched. Use chat to coordinate safely.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, message(reason))} + end + end + + def handle_event("cancel-request", _, socket) do + case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id) do + {:ok, _request} -> {:noreply, put_flash(socket, :info, "Request cancelled.")} + {:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))} + end + end + + def handle_event("start", _, socket), + do: transition(socket, &Help.start_assignment/2, "Help is in progress.") + + def handle_event("confirm", _, socket), + do: transition(socket, &Help.confirm_completion/2, "Your confirmation was saved.") + + def handle_event("withdraw", _, socket), + do: transition(socket, &Help.withdraw_assignment/2, "You withdrew from this request.") + + def handle_event("verify-code", %{"handover" => %{"code" => code}}, socket) do + case Help.verify_handover(socket.assigns.current_scope, socket.assigns.assignment.id, code) do + {:ok, _} -> {:noreply, put_flash(socket, :info, "Handover code verified.")} + {:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))} + end + end + + def handle_event("send-message", %{"message" => params}, socket) do + case Messaging.send_message(socket.assigns.current_scope, socket.assigns.assignment, params) do + {:ok, _} -> + request = Help.get_request!(socket.assigns.request.id) + + socket = + socket + |> load(request, socket.assigns.tracking_active) + |> push_event("reset-message-form", %{id: "message-form"}) + + {:noreply, socket} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, message(reason))} + end + end + + def handle_event("start-tracking", _, socket) do + case Tracking.start_session(socket.assigns.current_scope, socket.assigns.assignment) do + {:ok, _} -> {:noreply, assign(socket, :tracking_active, true)} + {:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))} + end + end + + def handle_event("location-update", params, socket) do + case Tracking.update_position(socket.assigns.current_scope, socket.assigns.assignment, params) do + {:ok, _} -> {:noreply, socket} + {:error, _} -> {:noreply, socket} + end + end + + def handle_event("location-error", _, socket) do + {:noreply, put_flash(socket, :error, "The browser could not share your location.")} + end + + def handle_event("stop-tracking", _, socket) do + case Tracking.stop_session(socket.assigns.current_scope, socket.assigns.assignment) do + {:ok, _} -> {:noreply, assign(socket, :tracking_active, false)} + {:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))} + end + end + + def handle_event("review", %{"review" => params}, socket) do + case Trust.submit_review(socket.assigns.current_scope, socket.assigns.assignment, params) do + {:ok, _} -> + {:noreply, + put_flash(socket, :info, "Review saved. It appears after both participants review.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, message(reason))} + end + end + + def handle_event("report", %{"report" => params}, socket) do + target = + cond do + socket.assigns.report_message_id -> + %{"message_id" => socket.assigns.report_message_id} + + Help.requester?(socket.assigns.current_scope, socket.assigns.request) && + socket.assigns.assignment -> + %{"assignment_id" => socket.assigns.assignment.id} + + true -> + %{"request_id" => socket.assigns.request.id} + end + + case Trust.report(socket.assigns.current_scope, Map.merge(params, target)) do + {:ok, _report} -> + {:noreply, + socket + |> assign(:report_form, report_form()) + |> assign(:report_message_id, nil) + |> put_flash(:info, "Report sent to moderators.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, message(reason))} + end + end + + def handle_event("select-report-message", %{"id" => message_id}, socket) do + message = + Enum.find(socket.assigns.messages, fn message -> + message.id == message_id and message.sender_id != socket.assigns.current_scope.user.id + end) + + if message do + {:noreply, assign(socket, :report_message_id, message.id)} + else + {:noreply, put_flash(socket, :error, "That message cannot be reported from this view.")} + end + end + + def handle_event("clear-report-message", _, socket), + do: {:noreply, assign(socket, :report_message_id, nil)} + + def handle_event("block-user", _, socket) do + case Trust.block(socket.assigns.current_scope, socket.assigns.other_user_id) do + {:ok, _block} -> + {:noreply, + socket + |> assign(:blocked_by_current, true) + |> assign(:messages, []) + |> assign(:positions, %{}) + |> assign(:tracking_active, false) + |> assign( + :markers, + Jason.encode!(request_markers(socket.assigns.current_scope, socket.assigns.request)) + ) + |> put_flash(:info, "User blocked. Their requests and new messages are hidden.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, message(reason))} + end + end + + def handle_event("unblock-user", _, socket) do + case Trust.unblock(socket.assigns.current_scope, socket.assigns.other_user_id) do + {:ok, _result} -> + {:noreply, + socket + |> assign(:blocked_by_current, false) + |> put_flash(:info, "User unblocked.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, message(reason))} + end + end + + @impl true + def terminate(_reason, socket) do + case socket.assigns do + %{tracking_active: true, current_scope: scope, assignment: assignment} + when not is_nil(assignment) -> + Tracking.stop_session(scope, assignment) + + _ -> + :ok + end + + :ok + end + + defp transition(socket, fun, success) do + case fun.(socket.assigns.current_scope, socket.assigns.assignment.id) do + {:ok, _} -> {:noreply, put_flash(socket, :info, success)} + {:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))} + end + end + + defp load(socket, request, tracking_active) do + assignment = request.assignment + participant = assignment && Help.participant?(socket.assigns.current_scope, assignment) + current_user_id = socket.assigns.current_scope.user.id + + other_user_id = + cond do + request.requester_id != current_user_id -> request.requester_id + assignment -> assignment.helper_id + true -> nil + end + + messages = + if participant, + do: Messaging.list_messages(socket.assigns.current_scope, assignment), + else: [] + + positions = + if participant, + do: Tracking.list_current_positions(socket.assigns.current_scope, assignment), + else: %{} + + socket + |> assign(:page_title, request.title) + |> assign(:request, request) + |> assign( + :structured_details, + structured_details(request, socket.assigns.current_scope.user.locale) + ) + |> assign(:requester_reputation, Trust.reputation(request.requester_id)) + |> assign( + :helper_reputation, + if(assignment, do: Trust.reputation(assignment.helper_id), else: nil) + ) + |> assign(:assignment, assignment) + |> assign(:participant, participant) + |> assign(:other_user_id, other_user_id) + |> assign( + :blocked_by_current, + other_user_id && Trust.blocked_by?(current_user_id, other_user_id) + ) + |> assign(:messages, messages) + |> assign(:report_message_id, nil) + |> assign(:positions, positions) + |> assign(:tracking_active, tracking_active) + |> assign(:message_form, to_form(%{"body" => ""}, as: :message)) + |> assign(:handover_form, to_form(%{"code" => ""}, as: :handover)) + |> assign(:review_form, to_form(%{"rating" => "5", "comment" => ""}, as: :review)) + |> assign(:report_form, report_form()) + |> assign( + :markers, + Jason.encode!( + request_markers(socket.assigns.current_scope, request) ++ position_markers(positions) + ) + ) + end + + defp report_form do + to_form(%{"reason" => "dangerous_request", "details" => ""}, as: :report) + end + + defp maybe_subscribe_assignment(socket, request) do + assignment = request.assignment + + if connected?(socket) && assignment && + Help.participant?(socket.assigns.current_scope, assignment) && + socket.assigns.subscribed_assignment_id != assignment.id do + Messaging.subscribe(assignment.id) + Tracking.subscribe(assignment.id) + assign(socket, :subscribed_assignment_id, assignment.id) + else + socket + end + end + + defp assign_positions(socket, positions) do + markers = + request_markers(socket.assigns.current_scope, socket.assigns.request) ++ + position_markers(positions) + + socket + |> assign(:positions, positions) + |> assign(:markers, Jason.encode!(markers)) + end + + defp structured_details(request, locale) do + request.category + |> WhoNeedHelp.Catalog.structured_fields() + |> Enum.flat_map(fn field -> + case request.structured_data[field["key"]] do + nil -> + [] + + value -> + label = localized(field["label"], locale) || field["key"] + + display = + if field["type"] == "select" do + option = + Enum.find(field["options"] || [], fn + %{"value" => candidate} -> to_string(candidate) == to_string(value) + candidate -> to_string(candidate) == to_string(value) + end) + + case option do + %{"label" => labels} -> localized(labels, locale) || to_string(value) + nil -> to_string(value) + candidate -> to_string(candidate) + end + else + to_string(value) + end + + [{label, display}] + end + end) + end + + defp localized(labels, locale) when is_map(labels), + do: labels[to_string(locale)] || labels["en"] + + defp localized(_labels, _locale), do: nil + + defp request_markers(scope, request) do + case Help.request_coordinates(scope, request) do + nil -> + [] + + coordinates -> + [ + Map.merge(coordinates, %{ + title: request.title, + location: request.location_label + }) + ] + end + end + + defp position_markers(positions) do + Enum.map(positions, fn {_user_id, point} -> + %{ + latitude: point.latitude, + longitude: point.longitude, + exact: true, + title: "Shared live location", + location: "Active match only" + } + end) + end + + defp message(:own_request), do: "You cannot accept your own request." + defp message(:not_open), do: "Another helper already accepted this request." + defp message(:invalid_code), do: "That handover code is not correct." + defp message(:forbidden), do: "You are not allowed to do that." + defp message(:invalid_transition), do: "That step is not available in the current state." + defp message(:account_not_eligible), do: "Confirm your account and ensure it is active first." + defp message(:blocked), do: "This interaction is blocked." + defp message(:rate_limited), do: "Too many actions in the configured time window." + defp message(%Ecto.Changeset{}), do: "Please check the entered data." + defp message(reason), do: "Could not complete the action: #{inspect(reason)}" + + @impl true + def render(assigns) do + ~H""" + + <.link navigate={~p"/requests"} class="btn btn-ghost btn-sm">← All requests +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + + This is not an emergency service. Contact local emergency services for immediate danger. + +
+
+
+
+
+ {WhoNeedHelp.Catalog.Category.name( + @request.category, + @current_scope.user.locale + )} + {@request.urgency} + {@request.status} +
+

{@request.title}

+

+ {@request.description} +

+
+
Pickup notes
+

{@request.pickup_instructions}

+
+
+
Category details
+
+
+
{label}
+
{value}
+
+
+
+
+
Area: {@request.location_label}
+
+ Requester: {@request.requester.display_name} +
+
+ Expires: {Calendar.strftime( + @request.expires_at, + "%d %b, %H:%M UTC" + )} +
+
+ Location: {@request.location_visibility} +
+
+ +
+ + Requester: {@requester_reputation.completed} completed + + + {@requester_reputation.unique_people} unique people + + + rating {@requester_reputation.rating || "—"} + +
+
+ +
+
+

Private match chat

+ participants only +
+
+

+ No messages yet. +

+
+
{message.sender.display_name}
+
{message.body}
+ +
+
+ <.form + id="message-form" + for={@message_form} + phx-submit="send-message" + class="mt-4 flex gap-2" + > + <.input + field={@message_form[:body]} + placeholder="Write a safe coordination message…" + class="grow" + /> + <.button class="btn btn-primary self-end">Send + +
+ +
+

Double-blind review

+

+ Your review is revealed only after both participants submit. +

+ <.form for={@review_form} phx-submit="review" class="mt-4 space-y-3"> + <.input + field={@review_form[:rating]} + type="select" + label="Rating" + options={[ + {"5 — excellent", 5}, + {"4 — good", 4}, + {"3 — okay", 3}, + {"2 — poor", 2}, + {"1 — unsafe", 1} + ]} + /> + <.input field={@review_form[:comment]} type="textarea" label="Comment (optional)" /> + <.button class="btn btn-outline">Submit review + +
+
+ + +
+
+ """ + end +end diff --git a/lib/who_need_help_web/locale.ex b/lib/who_need_help_web/locale.ex new file mode 100644 index 0000000..2daca78 --- /dev/null +++ b/lib/who_need_help_web/locale.ex @@ -0,0 +1,19 @@ +defmodule WhoNeedHelpWeb.Locale do + import Plug.Conn + + @locales ~w(en uk ru) + + def init(opts), do: opts + + def call(conn, _opts) do + locale = + conn.params["locale"] || + get_session(conn, :locale) || + get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:locale)]) || + "en" + + locale = if locale in @locales, do: locale, else: "en" + Gettext.put_locale(WhoNeedHelpWeb.Gettext, locale) + put_session(conn, :locale, locale) + end +end diff --git a/lib/who_need_help_web/presence.ex b/lib/who_need_help_web/presence.ex new file mode 100644 index 0000000..c16950f --- /dev/null +++ b/lib/who_need_help_web/presence.ex @@ -0,0 +1,5 @@ +defmodule WhoNeedHelpWeb.Presence do + use Phoenix.Presence, + otp_app: :who_need_help, + pubsub_server: WhoNeedHelp.PubSub +end diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex new file mode 100644 index 0000000..caf4bf2 --- /dev/null +++ b/lib/who_need_help_web/router.ex @@ -0,0 +1,102 @@ +defmodule WhoNeedHelpWeb.Router do + use WhoNeedHelpWeb, :router + + import WhoNeedHelpWeb.UserAuth + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {WhoNeedHelpWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + plug :fetch_current_scope_for_user + plug WhoNeedHelpWeb.Locale + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/healthz", WhoNeedHelpWeb do + pipe_through :api + + get "/live", HealthController, :live + get "/ready", HealthController, :ready + end + + scope "/", WhoNeedHelpWeb do + pipe_through :browser + + get "/", PageController, :home + get "/feedback", FeedbackController, :show + get "/safety", PageController, :safety + end + + # Other scopes may use custom stacks. + # scope "/api", WhoNeedHelpWeb do + # pipe_through :api + # end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:who_need_help, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: WhoNeedHelpWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end + + ## Authentication routes + + scope "/", WhoNeedHelpWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + get "/users/register", UserRegistrationController, :new + post "/users/register", UserRegistrationController, :create + end + + scope "/", WhoNeedHelpWeb do + pipe_through [:browser, :require_authenticated_user] + + get "/users/settings", UserSettingsController, :edit + put "/users/settings", UserSettingsController, :update + get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email + end + + scope "/", WhoNeedHelpWeb do + pipe_through :browser + + live_session :authenticated, + on_mount: [{WhoNeedHelpWeb.UserAuth, :ensure_authenticated}] do + live "/requests", RequestLive.Index, :index + live "/requests/new", RequestLive.New, :new + live "/requests/:id", RequestLive.Show, :show + live "/categories/proposals", CategoryProposalLive, :index + live "/profile", ProfileLive, :edit + live "/leaderboard", LeaderboardLive, :index + end + + live_session :moderation, + on_mount: [{WhoNeedHelpWeb.UserAuth, :ensure_moderator}] do + live "/moderation", ModerationLive, :index + end + end + + scope "/", WhoNeedHelpWeb do + pipe_through [:browser] + + get "/users/log-in", UserSessionController, :new + get "/users/log-in/:token", UserSessionController, :confirm + post "/users/log-in", UserSessionController, :create + delete "/users/log-out", UserSessionController, :delete + end +end diff --git a/lib/who_need_help_web/telemetry.ex b/lib/who_need_help_web/telemetry.ex new file mode 100644 index 0000000..663d086 --- /dev/null +++ b/lib/who_need_help_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule WhoNeedHelpWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://telemetry-metrics.hexdocs.pm + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("who_need_help.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("who_need_help.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("who_need_help.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("who_need_help.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("who_need_help.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {WhoNeedHelpWeb, :count_users, []} + ] + end +end diff --git a/lib/who_need_help_web/user_auth.ex b/lib/who_need_help_web/user_auth.ex new file mode 100644 index 0000000..d2af450 --- /dev/null +++ b/lib/who_need_help_web/user_auth.ex @@ -0,0 +1,285 @@ +defmodule WhoNeedHelpWeb.UserAuth do + use WhoNeedHelpWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.Scope + + # Make the remember me cookie valid for 14 days. This should match + # the session validity setting in UserToken. + @max_cookie_age_in_days 14 + @remember_me_cookie "_who_need_help_web_user_remember_me" + @remember_me_options [ + sign: true, + max_age: @max_cookie_age_in_days * 24 * 60 * 60, + same_site: "Lax" + ] + + # How old the session token should be before a new one is issued. When a request is made + # with a session token older than this value, then a new session token will be created + # and the session and remember-me cookies (if set) will be updated with the new token. + # Lowering this value will result in more tokens being created by active users. Increasing + # it will result in less time before a session token expires for a user to get issued a new + # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable + # the reissuing of tokens completely. + @session_reissue_age_in_days 7 + + @doc """ + Logs the user in. + + Redirects to the session's `:user_return_to` path + or falls back to the `signed_in_path/1`. + """ + def log_in_user(conn, user, params \\ %{}) do + user_return_to = get_session(conn, :user_return_to) + + conn + |> create_or_extend_session(user, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + WhoNeedHelpWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session(nil) + |> delete_resp_cookie(@remember_me_cookie, @remember_me_options) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session and remember me token. + + Will reissue the session token if it is older than the configured age. + """ + def fetch_current_scope_for_user(conn, _opts) do + with {token, conn} <- ensure_user_token(conn), + {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do + conn + |> assign(:current_scope, Scope.for_user(user)) + |> maybe_reissue_user_session_token(user, token_inserted_at) + else + nil -> assign(conn, :current_scope, Scope.for_user(nil)) + end + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)} + else + nil + end + end + end + + # Reissue the session token if it is older than the configured reissue age. + defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do + token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day) + + if token_age >= @session_reissue_age_in_days do + create_or_extend_session(conn, user, %{}) + else + conn + end + end + + # This function is the one responsible for creating session tokens + # and storing them safely in the session and cookies. It may be called + # either when logging in, during sudo mode, or to renew a session which + # will soon expire. + # + # When the session is created, rather than extended, the renew_session + # function will clear the session to avoid fixation attacks. See the + # renew_session function to customize this behaviour. + defp create_or_extend_session(conn, user, params) do + token = Accounts.generate_user_session_token(user) + remember_me = get_session(conn, :user_remember_me) + + conn + |> renew_session(user) + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params, remember_me) + end + + # Do not renew session if the user is already logged in + # to prevent CSRF errors or data being lost in tabs that are still open + defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn, _user) do + # delete_csrf_token() + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn, _user) do + delete_csrf_token() + + conn + |> configure_session(renew: true) + |> clear_session() + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _), + do: write_remember_me_cookie(conn, token) + + defp maybe_write_remember_me_cookie(conn, token, _params, true), + do: write_remember_me_cookie(conn, token) + + defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn + + defp write_remember_me_cookie(conn, token) do + conn + |> put_session(:user_remember_me, true) + |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options) + end + + defp put_token_in_session(conn, token) do + put_session(conn, :user_token, token) + end + + @doc """ + Plug for routes that require sudo mode. + """ + def require_sudo_mode(conn, _opts) do + if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do + conn + else + conn + |> put_flash(:error, "You must re-authenticate to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log-in") + |> halt() + end + end + + @doc """ + Plug for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns.current_scope do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + defp signed_in_path(_conn), do: ~p"/" + + @doc """ + Plug for routes that require the user to be authenticated. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns.current_scope && conn.assigns.current_scope.user do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log-in") + |> halt() + end + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + @doc "Loads the generated session token into LiveView's current scope." + def on_mount(:mount_current_scope, _params, session, socket) do + scope = live_scope(session) + set_live_locale(scope) + {:cont, Phoenix.Component.assign(socket, :current_scope, scope)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + case live_scope(session) do + %Scope{user: %Accounts.User{}} = scope -> + set_live_locale(scope) + {:cont, Phoenix.Component.assign(socket, :current_scope, scope)} + + _ -> + socket = + socket + |> Phoenix.Component.assign(:current_scope, nil) + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") + + {:halt, socket} + end + end + + def on_mount(:ensure_moderator, _params, session, socket) do + case live_scope(session) do + %Scope{user: %Accounts.User{} = user} = scope -> + if Accounts.moderator_authorized?(user) do + set_live_locale(scope) + {:cont, Phoenix.Component.assign(socket, :current_scope, scope)} + else + socket = + socket + |> Phoenix.Component.assign(:current_scope, scope) + |> Phoenix.LiveView.put_flash(:error, "Moderator access is required.") + |> Phoenix.LiveView.redirect(to: ~p"/requests") + + {:halt, socket} + end + + _ -> + socket = + socket + |> Phoenix.Component.assign(:current_scope, nil) + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") + + {:halt, socket} + end + end + + defp live_scope(%{"user_token" => token}) do + case Accounts.get_user_by_session_token(token) do + {user, _inserted_at} -> Scope.for_user(user) + nil -> nil + end + end + + defp live_scope(_session), do: nil + + defp set_live_locale(%Scope{user: %{locale: locale}}) when locale in ~w(en uk ru) do + Gettext.put_locale(WhoNeedHelpWeb.Gettext, locale) + end + + defp set_live_locale(_scope), do: Gettext.put_locale(WhoNeedHelpWeb.Gettext, "en") +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..6b8faf5 --- /dev/null +++ b/mix.exs @@ -0,0 +1,105 @@ +defmodule WhoNeedHelp.MixProject do + use Mix.Project + + def project do + [ + app: :who_need_help, + version: "0.1.0", + elixir: "~> 1.17", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + compilers: [:phoenix_live_view] ++ Mix.compilers(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {WhoNeedHelp.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:bcrypt_elixir, "~> 3.0"}, + {:phoenix, "~> 1.8.9"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.13"}, + {:postgrex, ">= 0.0.0"}, + {:geo_postgis, "~> 3.7"}, + {:oban, "~> 2.23"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.2.0"}, + {:lazy_html, ">= 0.1.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.5", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.2.0", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:daisyui, + github: "saadeghi/daisyui", + tag: "v5.5.20", + sparse: "packages/bundle", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.16"}, + {:gen_smtp, "~> 1.3"}, + {:req, "~> 0.5"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.2.0"}, + {:bandit, "~> 1.5"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["compile", "tailwind who_need_help", "esbuild who_need_help"], + "assets.deploy": [ + "tailwind who_need_help --minify", + "esbuild who_need_help --minify", + "phx.digest" + ], + precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..1da723a --- /dev/null +++ b/mix.lock @@ -0,0 +1,53 @@ +%{ + "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, + "daisyui": {:git, "https://github.com/saadeghi/daisyui.git", "22ecff57f2c391b80a75617325748cf4d13fdf47", [tag: "v5.5.20", sparse: "packages/bundle", depth: 1]}, + "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, + "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, + "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "ecto": {:hex, :ecto, "3.14.1", "7b740d87bdf45996aa0c2c2e081640906f10caa7ce5ba328fd294c7d49d0cc6f", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24b991956796700f467d0a3ef3d303138a3ef9ddddf8b98f43758ee067b20a30"}, + "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, + "geo": {:hex, :geo, "4.1.0", "64ba89a64cc400b5b16dd2f5bd644cb141776eb8c2ac5a983332c8d944936c12", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "19edb2b3398ca9f701b573b1fb11bc90951ebd64f18b06bd1bf35abe509a2934"}, + "geo_postgis": {:hex, :geo_postgis, "3.7.1", "614f25b42334a615bd54bb09c22030b1aac7bac8f829bd823ab1faccf093a324", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:geo, "~> 3.6 or ~> 4.0", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "c20d823c600d35b7fe9ddd5be03052bb7136c57d6f1775dbd46871545e405280"}, + "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oban": {:hex, :oban, "2.23.0", "1867d0fa4e8c7685217b02cc2632e3ee86c93da770e9029ff71304d9e62e53d7", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e5f0cec5abecce78dd08cb14dc5438db90ec3884987b44773ce76fe60dd3f81"}, + "phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.7", "d0f20871681216598e78baccd4f66d8686fdb8007821989bab508d293a3ed9ad", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "61e97938a4fcca6d6f2c836925623abf2f52a572cc8c6085e4074f3f6337e0eb"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "postgrex": {:hex, :postgrex, "0.22.3", "bf65941737ee7a9adbe4a64c91080310d11703da343e8ac9188aacb9eb9f6f02", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f018c13752b2b46e8d35d7e2d84c3276557cbfd880769109021a1d0ee36c1cfe"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "req": {:hex, :req, "0.6.3", "7fe5e68792ff0546e45d5919104fa1764a13694cfe3e48c8a0f32ad051ae77e4", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "e85b5c6c990e6c3f52bbba68e6f099118f2b8252825f96c7c3636b97a3de307d"}, + "swoosh": {:hex, :swoosh, "1.26.3", "9d8b60077305ce259298d9a1102e5be67cd3c41d1ea930c29e9288af195ca017", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c7683d070fe8f8aa9d174e61b01f2d527be73cd8ac40037b7109184941eb569f"}, + "tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, +} diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot new file mode 100644 index 0000000..696a462 --- /dev/null +++ b/priv/gettext/default.pot @@ -0,0 +1,100 @@ +## This file is a PO Template file. +## +## "msgid"s here are often extracted from source code. +## Add new messages manually only if they're dynamic +## messages that can't be statically extracted. +## +## Run "mix gettext.extract" to bring this file up to +## date. Leave "msgstr"s empty as changing them here has no +## effect: edit them in PO (.po) files instead. +# +msgid "" +msgstr "" + +#: lib/who_need_help_web/components/layouts/root.html.heex:56 +#, elixir-autogen, elixir-format +msgid "Account settings" +msgstr "" + +#: lib/who_need_help_web/components/core_components.ex:375 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:63 +#: lib/who_need_help_web/controllers/page_html/home.html.heex:20 +#, elixir-autogen, elixir-format +msgid "Ask for help" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:114 +#: lib/who_need_help_web/components/layouts.ex:129 +#, elixir-autogen, elixir-format +msgid "Attempting to reconnect" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:54 +#, elixir-autogen, elixir-format +msgid "Categories" +msgstr "" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:5 +#, elixir-autogen, elixir-format +msgid "Fast, local, voluntary help" +msgstr "" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:8 +#, elixir-autogen, elixir-format +msgid "Help can be closer than you think." +msgstr "" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:24 +#, elixir-autogen, elixir-format +msgid "Join the community" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:69 +#: lib/who_need_help_web/components/layouts/root.html.heex:66 +#, elixir-autogen, elixir-format +msgid "Log in" +msgstr "" + +#: lib/who_need_help_web/components/layouts/root.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Log out" +msgstr "" + +#: lib/who_need_help_web/components/layouts/root.html.heex:53 +#, elixir-autogen, elixir-format +msgid "Profile" +msgstr "" + +#: lib/who_need_help_web/components/layouts/root.html.heex:63 +#, elixir-autogen, elixir-format +msgid "Register" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:49 +#, elixir-autogen, elixir-format +msgid "Requests" +msgstr "" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:16 +#, elixir-autogen, elixir-format +msgid "See nearby requests" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:121 +#, elixir-autogen, elixir-format +msgid "Something went wrong!" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:106 +#, elixir-autogen, elixir-format +msgid "We can't find the internet" +msgstr "" + +#: lib/who_need_help_web/components/core_components.ex:81 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po new file mode 100644 index 0000000..2452df5 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -0,0 +1,100 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: lib/who_need_help_web/components/layouts/root.html.heex:56 +#, elixir-autogen, elixir-format +msgid "Account settings" +msgstr "" + +#: lib/who_need_help_web/components/core_components.ex:375 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:63 +#: lib/who_need_help_web/controllers/page_html/home.html.heex:20 +#, elixir-autogen, elixir-format +msgid "Ask for help" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:114 +#: lib/who_need_help_web/components/layouts.ex:129 +#, elixir-autogen, elixir-format +msgid "Attempting to reconnect" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:54 +#, elixir-autogen, elixir-format +msgid "Categories" +msgstr "" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:5 +#, elixir-autogen, elixir-format +msgid "Fast, local, voluntary help" +msgstr "" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:8 +#, elixir-autogen, elixir-format +msgid "Help can be closer than you think." +msgstr "" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:24 +#, elixir-autogen, elixir-format +msgid "Join the community" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:69 +#: lib/who_need_help_web/components/layouts/root.html.heex:66 +#, elixir-autogen, elixir-format +msgid "Log in" +msgstr "" + +#: lib/who_need_help_web/components/layouts/root.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Log out" +msgstr "" + +#: lib/who_need_help_web/components/layouts/root.html.heex:53 +#, elixir-autogen, elixir-format +msgid "Profile" +msgstr "" + +#: lib/who_need_help_web/components/layouts/root.html.heex:63 +#, elixir-autogen, elixir-format +msgid "Register" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:49 +#, elixir-autogen, elixir-format +msgid "Requests" +msgstr "" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:16 +#, elixir-autogen, elixir-format +msgid "See nearby requests" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:121 +#, elixir-autogen, elixir-format +msgid "Something went wrong!" +msgstr "" + +#: lib/who_need_help_web/components/layouts.ex:106 +#, elixir-autogen, elixir-format +msgid "We can't find the internet" +msgstr "" + +#: lib/who_need_help_web/components/core_components.ex:81 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/ru/LC_MESSAGES/default.po b/priv/gettext/ru/LC_MESSAGES/default.po new file mode 100644 index 0000000..b9d6a63 --- /dev/null +++ b/priv/gettext/ru/LC_MESSAGES/default.po @@ -0,0 +1,100 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: ru\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100 != 11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10||n%100>=20) ? 1 : 2);\n" + +#: lib/who_need_help_web/components/layouts/root.html.heex:56 +#, elixir-autogen, elixir-format +msgid "Account settings" +msgstr "Настройки аккаунта" + +#: lib/who_need_help_web/components/core_components.ex:375 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "Действия" + +#: lib/who_need_help_web/components/layouts.ex:63 +#: lib/who_need_help_web/controllers/page_html/home.html.heex:20 +#, elixir-autogen, elixir-format +msgid "Ask for help" +msgstr "Попросить помощь" + +#: lib/who_need_help_web/components/layouts.ex:114 +#: lib/who_need_help_web/components/layouts.ex:129 +#, elixir-autogen, elixir-format +msgid "Attempting to reconnect" +msgstr "Пытаемся восстановить соединение" + +#: lib/who_need_help_web/components/layouts.ex:54 +#, elixir-autogen, elixir-format +msgid "Categories" +msgstr "Категории" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:5 +#, elixir-autogen, elixir-format +msgid "Fast, local, voluntary help" +msgstr "Быстрая, локальная, добровольная помощь" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:8 +#, elixir-autogen, elixir-format +msgid "Help can be closer than you think." +msgstr "Помощь может быть ближе, чем кажется." + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:24 +#, elixir-autogen, elixir-format +msgid "Join the community" +msgstr "Присоединиться к сообществу" + +#: lib/who_need_help_web/components/layouts.ex:69 +#: lib/who_need_help_web/components/layouts/root.html.heex:66 +#, elixir-autogen, elixir-format +msgid "Log in" +msgstr "Войти" + +#: lib/who_need_help_web/components/layouts/root.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Log out" +msgstr "Выйти" + +#: lib/who_need_help_web/components/layouts/root.html.heex:53 +#, elixir-autogen, elixir-format +msgid "Profile" +msgstr "Профиль" + +#: lib/who_need_help_web/components/layouts/root.html.heex:63 +#, elixir-autogen, elixir-format +msgid "Register" +msgstr "Регистрация" + +#: lib/who_need_help_web/components/layouts.ex:49 +#, elixir-autogen, elixir-format +msgid "Requests" +msgstr "Заявки" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:16 +#, elixir-autogen, elixir-format +msgid "See nearby requests" +msgstr "Заявки рядом" + +#: lib/who_need_help_web/components/layouts.ex:121 +#, elixir-autogen, elixir-format +msgid "Something went wrong!" +msgstr "Что-то пошло не так!" + +#: lib/who_need_help_web/components/layouts.ex:106 +#, elixir-autogen, elixir-format +msgid "We can't find the internet" +msgstr "Нет соединения с интернетом" + +#: lib/who_need_help_web/components/core_components.ex:81 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "закрыть" diff --git a/priv/gettext/ru/LC_MESSAGES/errors.po b/priv/gettext/ru/LC_MESSAGES/errors.po new file mode 100644 index 0000000..66e9a13 --- /dev/null +++ b/priv/gettext/ru/LC_MESSAGES/errors.po @@ -0,0 +1,111 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: ru\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100 != 11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10||n%100>=20) ? 1 : 2);\n" + +msgid "can't be blank" +msgstr "" + +msgid "has already been taken" +msgstr "" + +msgid "is invalid" +msgstr "" + +msgid "must be accepted" +msgstr "" + +msgid "has invalid format" +msgstr "" + +msgid "has an invalid entry" +msgstr "" + +msgid "is reserved" +msgstr "" + +msgid "does not match confirmation" +msgstr "" + +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/uk/LC_MESSAGES/default.po b/priv/gettext/uk/LC_MESSAGES/default.po new file mode 100644 index 0000000..83a8381 --- /dev/null +++ b/priv/gettext/uk/LC_MESSAGES/default.po @@ -0,0 +1,100 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: uk\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100 != 11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10||n%100>=20) ? 1 : 2);\n" + +#: lib/who_need_help_web/components/layouts/root.html.heex:56 +#, elixir-autogen, elixir-format +msgid "Account settings" +msgstr "Налаштування акаунта" + +#: lib/who_need_help_web/components/core_components.ex:375 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "Дії" + +#: lib/who_need_help_web/components/layouts.ex:63 +#: lib/who_need_help_web/controllers/page_html/home.html.heex:20 +#, elixir-autogen, elixir-format +msgid "Ask for help" +msgstr "Попросити допомогу" + +#: lib/who_need_help_web/components/layouts.ex:114 +#: lib/who_need_help_web/components/layouts.ex:129 +#, elixir-autogen, elixir-format +msgid "Attempting to reconnect" +msgstr "Намагаємося відновити з’єднання" + +#: lib/who_need_help_web/components/layouts.ex:54 +#, elixir-autogen, elixir-format +msgid "Categories" +msgstr "Категорії" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:5 +#, elixir-autogen, elixir-format +msgid "Fast, local, voluntary help" +msgstr "Швидка, локальна, добровільна допомога" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:8 +#, elixir-autogen, elixir-format +msgid "Help can be closer than you think." +msgstr "Допомога може бути ближче, ніж здається." + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:24 +#, elixir-autogen, elixir-format +msgid "Join the community" +msgstr "Долучитися до спільноти" + +#: lib/who_need_help_web/components/layouts.ex:69 +#: lib/who_need_help_web/components/layouts/root.html.heex:66 +#, elixir-autogen, elixir-format +msgid "Log in" +msgstr "Увійти" + +#: lib/who_need_help_web/components/layouts/root.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Log out" +msgstr "Вийти" + +#: lib/who_need_help_web/components/layouts/root.html.heex:53 +#, elixir-autogen, elixir-format +msgid "Profile" +msgstr "Профіль" + +#: lib/who_need_help_web/components/layouts/root.html.heex:63 +#, elixir-autogen, elixir-format +msgid "Register" +msgstr "Реєстрація" + +#: lib/who_need_help_web/components/layouts.ex:49 +#, elixir-autogen, elixir-format +msgid "Requests" +msgstr "Запити" + +#: lib/who_need_help_web/controllers/page_html/home.html.heex:16 +#, elixir-autogen, elixir-format +msgid "See nearby requests" +msgstr "Запити поруч" + +#: lib/who_need_help_web/components/layouts.ex:121 +#, elixir-autogen, elixir-format +msgid "Something went wrong!" +msgstr "Щось пішло не так!" + +#: lib/who_need_help_web/components/layouts.ex:106 +#, elixir-autogen, elixir-format +msgid "We can't find the internet" +msgstr "Немає з’єднання з інтернетом" + +#: lib/who_need_help_web/components/core_components.ex:81 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "закрити" diff --git a/priv/gettext/uk/LC_MESSAGES/errors.po b/priv/gettext/uk/LC_MESSAGES/errors.po new file mode 100644 index 0000000..75dcbce --- /dev/null +++ b/priv/gettext/uk/LC_MESSAGES/errors.po @@ -0,0 +1,111 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: uk\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100 != 11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10||n%100>=20) ? 1 : 2);\n" + +msgid "can't be blank" +msgstr "" + +msgid "has already been taken" +msgstr "" + +msgid "is invalid" +msgstr "" + +msgid "must be accepted" +msgstr "" + +msgid "has invalid format" +msgstr "" + +msgid "has an invalid entry" +msgstr "" + +msgid "is reserved" +msgstr "" + +msgid "does not match confirmation" +msgstr "" + +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/migrations/20260718003125_create_users_auth_tables.exs b/priv/repo/migrations/20260718003125_create_users_auth_tables.exs new file mode 100644 index 0000000..c0b1805 --- /dev/null +++ b/priv/repo/migrations/20260718003125_create_users_auth_tables.exs @@ -0,0 +1,32 @@ +defmodule WhoNeedHelp.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users, primary_key: false) do + add :id, :binary_id, primary_key: true + add :email, :citext, null: false + add :hashed_password, :string + add :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + + create table(:users_tokens, primary_key: false) do + add :id, :binary_id, primary_key: true + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + add :authenticated_at, :utc_datetime + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20260718003613_create_mutual_aid_domain.exs b/priv/repo/migrations/20260718003613_create_mutual_aid_domain.exs new file mode 100644 index 0000000..9ffd99b --- /dev/null +++ b/priv/repo/migrations/20260718003613_create_mutual_aid_domain.exs @@ -0,0 +1,230 @@ +defmodule WhoNeedHelp.Repo.Migrations.CreateMutualAidDomain do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS postgis", "" + + alter table(:users) do + add :display_name, :string + add :bio, :text + add :locale, :string, null: false, default: "en" + add :location_visibility, :string, null: false, default: "approximate_public" + add :direct_message_policy, :string, null: false, default: "verified_accounts" + add :role, :string, null: false, default: "user" + add :tip_url, :string + add :accepted_terms_at, :utc_datetime + end + + create table(:social_identities, primary_key: false) do + add :id, :binary_id, primary_key: true + add :provider, :string, null: false + add :provider_uid, :string + add :profile_url, :string, null: false + add :handle, :string + add :verified_at, :utc_datetime + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + timestamps(type: :utc_datetime) + end + + create index(:social_identities, [:user_id]) + + create unique_index(:social_identities, [:provider, :provider_uid], + where: "provider_uid IS NOT NULL" + ) + + create table(:categories, primary_key: false) do + add :id, :binary_id, primary_key: true + add :slug, :string, null: false + add :names, :map, null: false, default: %{} + add :description, :text + add :active, :boolean, null: false, default: true + add :sort_order, :integer, null: false, default: 0 + add :structured_fields, :map, null: false, default: %{} + add :parent_id, references(:categories, type: :binary_id, on_delete: :nilify_all) + timestamps(type: :utc_datetime) + end + + create unique_index(:categories, [:slug]) + create index(:categories, [:parent_id]) + + create table(:category_proposals, primary_key: false) do + add :id, :binary_id, primary_key: true + add :proposed_name, :string, null: false + add :reason, :text, null: false + add :status, :string, null: false, default: "open" + add :proposer_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :parent_id, references(:categories, type: :binary_id, on_delete: :nilify_all) + add :merged_into_id, references(:categories, type: :binary_id, on_delete: :nilify_all) + timestamps(type: :utc_datetime) + end + + create index(:category_proposals, [:proposer_id]) + create index(:category_proposals, [:status]) + + create table(:category_votes, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :proposal_id, + references(:category_proposals, type: :binary_id, on_delete: :delete_all), + null: false + + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + timestamps(type: :utc_datetime, updated_at: false) + end + + create unique_index(:category_votes, [:proposal_id, :user_id]) + + create table(:help_requests, primary_key: false) do + add :id, :binary_id, primary_key: true + add :title, :string, null: false + add :description, :text, null: false + add :pickup_instructions, :text + add :location_label, :string, null: false + add :location, :geometry, null: false + add :status, :string, null: false, default: "open" + add :urgency, :string, null: false, default: "now" + add :location_visibility, :string, null: false, default: "approximate_public" + add :expires_at, :utc_datetime, null: false + add :cancelled_at, :utc_datetime + add :completed_at, :utc_datetime + add :requester_id, references(:users, type: :binary_id, on_delete: :restrict), null: false + + add :category_id, references(:categories, type: :binary_id, on_delete: :restrict), + null: false + + timestamps(type: :utc_datetime) + end + + create index(:help_requests, [:requester_id]) + create index(:help_requests, [:category_id, :status, :expires_at]) + create index(:help_requests, [:location], using: :gist) + + create table(:help_assignments, primary_key: false) do + add :id, :binary_id, primary_key: true + add :status, :string, null: false, default: "accepted" + add :handover_code_hash, :binary, null: false + add :handover_verified_at, :utc_datetime + add :requester_confirmed_at, :utc_datetime + add :helper_confirmed_at, :utc_datetime + add :proximity_observed_at, :utc_datetime + add :accepted_at, :utc_datetime, null: false + add :started_at, :utc_datetime + add :completed_at, :utc_datetime + + add :request_id, references(:help_requests, type: :binary_id, on_delete: :restrict), + null: false + + add :helper_id, references(:users, type: :binary_id, on_delete: :restrict), null: false + timestamps(type: :utc_datetime) + end + + create unique_index(:help_assignments, [:request_id]) + create index(:help_assignments, [:helper_id, :status]) + + create table(:messages, primary_key: false) do + add :id, :binary_id, primary_key: true + add :body, :text, null: false + add :read_at, :utc_datetime + + add :assignment_id, + references(:help_assignments, type: :binary_id, on_delete: :delete_all), + null: false + + add :sender_id, references(:users, type: :binary_id, on_delete: :restrict), null: false + timestamps(type: :utc_datetime) + end + + create index(:messages, [:assignment_id, :inserted_at]) + + create table(:tracking_sessions, primary_key: false) do + add :id, :binary_id, primary_key: true + add :active, :boolean, null: false, default: true + add :visibility, :string, null: false, default: "active_match" + add :started_at, :utc_datetime, null: false + add :ended_at, :utc_datetime + + add :assignment_id, + references(:help_assignments, type: :binary_id, on_delete: :delete_all), + null: false + + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + timestamps(type: :utc_datetime) + end + + create unique_index(:tracking_sessions, [:assignment_id, :user_id], + where: "active", + name: :tracking_sessions_one_active_per_user + ) + + create table(:tracking_positions, primary_key: false) do + add :id, :binary_id, primary_key: true + add :position, :geometry, null: false + add :accuracy_meters, :float + add :captured_at, :utc_datetime, null: false + + add :tracking_session_id, + references(:tracking_sessions, type: :binary_id, on_delete: :delete_all), + null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:tracking_positions, [:tracking_session_id]) + create index(:tracking_positions, [:position], using: :gist) + + create table(:reviews, primary_key: false) do + add :id, :binary_id, primary_key: true + add :rating, :integer, null: false + add :comment, :text + add :revealed_at, :utc_datetime + + add :assignment_id, + references(:help_assignments, type: :binary_id, on_delete: :delete_all), + null: false + + add :reviewer_id, references(:users, type: :binary_id, on_delete: :restrict), null: false + add :reviewee_id, references(:users, type: :binary_id, on_delete: :restrict), null: false + timestamps(type: :utc_datetime) + end + + create unique_index(:reviews, [:assignment_id, :reviewer_id]) + create constraint(:reviews, :review_cannot_target_self, check: "reviewer_id <> reviewee_id") + + create table(:reports, primary_key: false) do + add :id, :binary_id, primary_key: true + add :reason, :string, null: false + add :details, :text, null: false + add :status, :string, null: false, default: "open" + add :reporter_id, references(:users, type: :binary_id, on_delete: :restrict), null: false + add :request_id, references(:help_requests, type: :binary_id, on_delete: :nilify_all) + add :assignment_id, references(:help_assignments, type: :binary_id, on_delete: :nilify_all) + add :message_id, references(:messages, type: :binary_id, on_delete: :nilify_all) + timestamps(type: :utc_datetime) + end + + create index(:reports, [:status, :inserted_at]) + create index(:reports, [:reporter_id]) + + create table(:blocks, primary_key: false) do + add :id, :binary_id, primary_key: true + add :blocker_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :blocked_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + timestamps(type: :utc_datetime, updated_at: false) + end + + create unique_index(:blocks, [:blocker_id, :blocked_id]) + create constraint(:blocks, :block_cannot_target_self, check: "blocker_id <> blocked_id") + + create table(:audit_events, primary_key: false) do + add :id, :binary_id, primary_key: true + add :action, :string, null: false + add :target_type, :string, null: false + add :target_id, :binary_id + add :metadata, :map, null: false, default: %{} + add :actor_id, references(:users, type: :binary_id, on_delete: :nilify_all) + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:audit_events, [:target_type, :target_id, :inserted_at]) + end +end diff --git a/priv/repo/migrations/20260718003614_add_oban_jobs.exs b/priv/repo/migrations/20260718003614_add_oban_jobs.exs new file mode 100644 index 0000000..7ad791e --- /dev/null +++ b/priv/repo/migrations/20260718003614_add_oban_jobs.exs @@ -0,0 +1,6 @@ +defmodule WhoNeedHelp.Repo.Migrations.AddObanJobs do + use Ecto.Migration + + def up, do: Oban.Migration.up(version: 14) + def down, do: Oban.Migration.down(version: 1) +end diff --git a/priv/repo/migrations/20260718012612_add_trust_and_moderation_controls.exs b/priv/repo/migrations/20260718012612_add_trust_and_moderation_controls.exs new file mode 100644 index 0000000..6776ddf --- /dev/null +++ b/priv/repo/migrations/20260718012612_add_trust_and_moderation_controls.exs @@ -0,0 +1,97 @@ +defmodule WhoNeedHelp.Repo.Migrations.AddTrustAndModerationControls do + use Ecto.Migration + + def change do + alter table(:users) do + add :moderation_status, :string, null: false, default: "active" + add :moderation_note, :text + end + + create index(:users, [:moderation_status, :inserted_at]) + + alter table(:category_proposals) do + add :reviewed_by_id, references(:users, type: :binary_id, on_delete: :nilify_all) + add :reviewed_at, :utc_datetime + add :moderation_note, :text + end + + create index(:category_proposals, [:reviewed_by_id]) + + alter table(:help_requests) do + add :hidden_at, :utc_datetime + add :hidden_reason, :text + end + + create index(:help_requests, [:status, :hidden_at, :expires_at]) + + alter table(:help_assignments) do + add :helper_movement_observed_at, :utc_datetime + end + + alter table(:tracking_sessions) do + add :distance_meters, :float, null: false, default: 0.0 + add :sample_count, :integer, null: false, default: 0 + add :movement_observed_at, :utc_datetime + end + + create constraint(:tracking_sessions, :tracking_distance_non_negative, + check: "distance_meters >= 0" + ) + + create constraint(:tracking_sessions, :tracking_sample_count_non_negative, + check: "sample_count >= 0" + ) + + alter table(:reports) do + add :resolution_note, :text + add :reviewed_at, :utc_datetime + add :reviewed_by_id, references(:users, type: :binary_id, on_delete: :nilify_all) + end + + create index(:reports, [:reviewed_by_id]) + + create constraint(:reports, :report_exactly_one_target, + check: "num_nonnulls(request_id, assignment_id, message_id) = 1" + ) + + create table(:rate_limit_buckets, primary_key: false) do + add :id, :binary_id, primary_key: true + add :action, :string, null: false + add :scope_hash, :binary, null: false + add :window_started_at, :utc_datetime, null: false + add :count, :integer, null: false, default: 0 + add :expires_at, :utc_datetime, null: false + timestamps(type: :utc_datetime) + end + + create unique_index(:rate_limit_buckets, [:action, :scope_hash, :window_started_at]) + create index(:rate_limit_buckets, [:expires_at]) + create constraint(:rate_limit_buckets, :rate_limit_count_non_negative, check: "count >= 0") + + create table(:abuse_signals, primary_key: false) do + add :id, :binary_id, primary_key: true + add :kind, :string, null: false + add :status, :string, null: false, default: "open" + add :metadata, :map, null: false, default: %{} + add :reviewed_at, :utc_datetime + add :review_note, :text + add :subject_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + + add :assignment_id, + references(:help_assignments, type: :binary_id, on_delete: :delete_all) + + add :reviewed_by_id, references(:users, type: :binary_id, on_delete: :nilify_all) + timestamps(type: :utc_datetime) + end + + create index(:abuse_signals, [:status, :inserted_at]) + create index(:abuse_signals, [:subject_id, :kind]) + create index(:abuse_signals, [:assignment_id]) + create index(:abuse_signals, [:reviewed_by_id]) + + create unique_index(:abuse_signals, [:kind, :subject_id, :assignment_id], + where: "assignment_id IS NOT NULL", + name: :abuse_signals_one_kind_per_assignment + ) + end +end diff --git a/priv/repo/migrations/20260718013929_add_structured_data_to_help_requests.exs b/priv/repo/migrations/20260718013929_add_structured_data_to_help_requests.exs new file mode 100644 index 0000000..a98c4eb --- /dev/null +++ b/priv/repo/migrations/20260718013929_add_structured_data_to_help_requests.exs @@ -0,0 +1,9 @@ +defmodule WhoNeedHelp.Repo.Migrations.AddStructuredDataToHelpRequests do + use Ecto.Migration + + def change do + alter table(:help_requests) do + add :structured_data, :map, null: false, default: %{} + end + end +end diff --git a/priv/repo/migrations/20260718114233_add_blocks_blocked_id_blocker_id_index.exs b/priv/repo/migrations/20260718114233_add_blocks_blocked_id_blocker_id_index.exs new file mode 100644 index 0000000..dae1122 --- /dev/null +++ b/priv/repo/migrations/20260718114233_add_blocks_blocked_id_blocker_id_index.exs @@ -0,0 +1,7 @@ +defmodule WhoNeedHelp.Repo.Migrations.AddBlocksBlockedIdBlockerIdIndex do + use Ecto.Migration + + def change do + create index(:blocks, [:blocked_id, :blocker_id]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..cf705ef --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# WhoNeedHelp.Repo.insert!(%WhoNeedHelp.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y literal 0 HcmV?d00001 diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/priv/static/manifest.webmanifest b/priv/static/manifest.webmanifest new file mode 100644 index 0000000..dab88e7 --- /dev/null +++ b/priv/static/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Who Need Help", + "short_name": "Who Need Help", + "description": "Fast, local, voluntary mutual aid.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#16a34a", + "icons": [ + { + "src": "/images/logo.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/priv/static/sw.js b/priv/static/sw.js new file mode 100644 index 0000000..384b1b2 --- /dev/null +++ b/priv/static/sw.js @@ -0,0 +1,33 @@ +const CACHE = "who-need-help-static-v1" +const SHELL = ["/assets/css/app.css", "/assets/js/app.js", "/images/logo.svg"] + +self.addEventListener("install", event => { + event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL))) +}) + +self.addEventListener("activate", event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key))) + ) + ) +}) + +self.addEventListener("fetch", event => { + const url = new URL(event.request.url) + const isStatic = + url.origin === self.location.origin && + (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/images/")) + + if (event.request.method === "GET" && isStatic) { + event.respondWith( + caches.match(event.request).then(cached => + cached || fetch(event.request).then(response => { + const copy = response.clone() + caches.open(CACHE).then(cache => cache.put(event.request, copy)) + return response + }) + ) + ) + } +}) diff --git a/rel/env.sh.eex b/rel/env.sh.eex new file mode 100644 index 0000000..61e15be --- /dev/null +++ b/rel/env.sh.eex @@ -0,0 +1,10 @@ +#!/bin/sh + +export RELEASE_DISTRIBUTION="${RELEASE_DISTRIBUTION:-name}" + +if [ -z "${RELEASE_NODE:-}" ]; then + release_ip=$(hostname -i) + release_ip=${release_ip%% *} + export RELEASE_NODE="who_need_help@$release_ip" + unset release_ip +fi diff --git a/rel/overlays/bin/await_migrations b/rel/overlays/bin/await_migrations new file mode 100755 index 0000000..2772769 --- /dev/null +++ b/rel/overlays/bin/await_migrations @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +exec ./who_need_help eval WhoNeedHelp.Release.await_migrations diff --git a/rel/overlays/bin/migrate b/rel/overlays/bin/migrate new file mode 100755 index 0000000..d3859dd --- /dev/null +++ b/rel/overlays/bin/migrate @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +exec ./who_need_help eval WhoNeedHelp.Release.migrate diff --git a/rel/overlays/bin/migrate.bat b/rel/overlays/bin/migrate.bat new file mode 100755 index 0000000..ebd771c --- /dev/null +++ b/rel/overlays/bin/migrate.bat @@ -0,0 +1 @@ +call "%~dp0\who_need_help" eval WhoNeedHelp.Release.migrate diff --git a/rel/overlays/bin/server b/rel/overlays/bin/server new file mode 100755 index 0000000..18b7944 --- /dev/null +++ b/rel/overlays/bin/server @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +PHX_SERVER=true exec ./who_need_help start diff --git a/rel/overlays/bin/server.bat b/rel/overlays/bin/server.bat new file mode 100755 index 0000000..2fffe4b --- /dev/null +++ b/rel/overlays/bin/server.bat @@ -0,0 +1,2 @@ +set PHX_SERVER=true +call "%~dp0\who_need_help" start diff --git a/scripts/android-build.sh b/scripts/android-build.sh new file mode 100755 index 0000000..ee71c32 --- /dev/null +++ b/scripts/android-build.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +ENV_FILE="$ROOT/.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "Missing $ENV_FILE. Copy .env.example to .env and configure WNH_DEBUG_BASE_URL." >&2 + exit 1 +fi + +set -a +. "$ENV_FILE" +set +a + +: "${WNH_DEBUG_BASE_URL:?Set WNH_DEBUG_BASE_URL in .env}" + +exec docker build \ + --build-arg "WNH_DEBUG_BASE_URL=$WNH_DEBUG_BASE_URL" \ + --target artifact \ + --output "type=local,dest=$ROOT/android/dist" \ + "$ROOT/android" diff --git a/scripts/bootstrap-admin.sh b/scripts/bootstrap-admin.sh new file mode 100755 index 0000000..1c67b62 --- /dev/null +++ b/scripts/bootstrap-admin.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ] || [ "$2" != "--confirm" ]; then + echo "Usage: $0 EMAIL --confirm [compose|kind]" >&2 + echo "This changes exactly one existing account and only while no administrator exists." >&2 + exit 1 +fi + +EMAIL=$1 +MODE=${3:-compose} +ENCODED_EMAIL=$(printf %s "$EMAIL" | base64 | tr -d '\n') +EXPRESSION="case Base.decode64!(\"$ENCODED_EMAIL\") |> WhoNeedHelp.Release.bootstrap_admin() do {:ok, admin} -> IO.inspect({:ok, admin}); {:error, reason} -> raise \"Admin bootstrap failed: #{inspect(reason)}\" end" + +case "$MODE" in + compose) + TARGET=$(docker compose -p who_need_help ps -q web | head -n 1) + + if [ -z "$TARGET" ]; then + echo "No running Compose web replica was found." >&2 + exit 1 + fi + + docker exec "$TARGET" /app/bin/who_need_help rpc "$EXPRESSION" + ;; + kind) + ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + PATH="$ROOT/.tools/bin:$PATH" + export PATH + TARGET=$( + kubectl --context kind-who-need-help --namespace who-need-help get pods \ + -l app.kubernetes.io/component=web \ + -o jsonpath='{.items[0].metadata.name}' + ) + + if [ -z "$TARGET" ]; then + echo "No running kind web replica was found." >&2 + exit 1 + fi + + kubectl --context kind-who-need-help --namespace who-need-help exec "$TARGET" -- \ + /app/bin/who_need_help rpc "$EXPRESSION" + ;; + *) + echo "Mode must be compose or kind." >&2 + exit 1 + ;; +esac diff --git a/scripts/bootstrap-kubernetes-tools.sh b/scripts/bootstrap-kubernetes-tools.sh new file mode 100755 index 0000000..5b8dfc5 --- /dev/null +++ b/scripts/bootstrap-kubernetes-tools.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +TOOLS="$ROOT/.tools/bin" +mkdir -p "$TOOLS" + +KUBECTL_VERSION=v1.36.2 +HELM_VERSION=v4.2.3 +KIND_VERSION=v0.32.0 + +case "$(uname -m)" in + x86_64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +verified_download() { + url=$1 + checksum_url=$2 + destination=$3 + archive=$4 + temp="${destination}.download" + checksum="${temp}.sha256" + + curl --fail --location --silent --show-error "$url" --output "$temp" + curl --fail --location --silent --show-error "$checksum_url" --output "$checksum" + + expected=$(awk '{print $1}' "$checksum") + actual=$(sha256sum "$temp" | awk '{print $1}') + if [ "$expected" != "$actual" ]; then + echo "Checksum mismatch for $url" >&2 + exit 1 + fi + + if [ "$archive" = "tar" ]; then + directory="${temp}.dir" + mkdir -p "$directory" + tar -xzf "$temp" -C "$directory" + install -m 0755 "$directory/linux-${ARCH}/helm" "$destination" + else + install -m 0755 "$temp" "$destination" + fi +} + +if [ ! -x "$TOOLS/kubectl" ]; then + verified_download \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl.sha256" \ + "$TOOLS/kubectl" binary +fi + +if [ ! -x "$TOOLS/kind" ]; then + verified_download \ + "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-${ARCH}" \ + "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-${ARCH}.sha256sum" \ + "$TOOLS/kind" binary +fi + +if [ ! -x "$TOOLS/helm" ]; then + verified_download \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz.sha256sum" \ + "$TOOLS/helm" tar +fi + +"$TOOLS/kubectl" version --client +"$TOOLS/kind" version +"$TOOLS/helm" version diff --git a/scripts/codex-review-categories.sh b/scripts/codex-review-categories.sh new file mode 100755 index 0000000..543a314 --- /dev/null +++ b/scripts/codex-review-categories.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +INPUT=${1:-} +OUTPUT=${2:-} + +if [ -z "$INPUT" ] || [ -z "$OUTPUT" ]; then + echo "Usage: $0 proposals.json recommendations.json" >&2 + exit 1 +fi + +if [ ! -f "$INPUT" ]; then + echo "Input file does not exist: $INPUT" >&2 + exit 1 +fi + +if ! command -v codex >/dev/null 2>&1; then + echo "Local Codex CLI is not installed." >&2 + exit 1 +fi + +LOGIN_STATUS=$(codex login status 2>&1) +if [ "$LOGIN_STATUS" != "Logged in using ChatGPT" ]; then + echo "Codex must be logged in through the user's ChatGPT subscription." >&2 + echo "Observed status: $LOGIN_STATUS" >&2 + exit 1 +fi + +{ + echo "Review the following community proposals for mutual-aid categories." + echo "Return advisory recommendations only; never claim that a database action happened." + echo "Prioritize safe, legal, non-commercial mutual aid. Flag ambiguous or risky proposals for manual_review." + echo "Prefer merging duplicates into an existing parent when the input supports that conclusion." + echo "Input JSON follows:" + sed -n '1,$p' "$INPUT" +} | codex exec \ + --ephemeral \ + --sandbox read-only \ + --output-schema "$ROOT/docs/category-moderation-output.schema.json" \ + --output-last-message "$OUTPUT" \ + - + +echo "Advisory recommendations written to $OUTPUT" diff --git a/scripts/compose-up.sh b/scripts/compose-up.sh new file mode 100755 index 0000000..bc4198b --- /dev/null +++ b/scripts/compose-up.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" +CODEX_SESSION_ID=${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-not-configured}} +export CODEX_SESSION_ID +docker compose -p who_need_help up -d --build --wait +"$ROOT/scripts/verify-realtime-cluster.sh" compose diff --git a/scripts/kind-up.sh b/scripts/kind-up.sh new file mode 100755 index 0000000..152f3ca --- /dev/null +++ b/scripts/kind-up.sh @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +"$ROOT/scripts/bootstrap-kubernetes-tools.sh" +PATH="$ROOT/.tools/bin:$PATH" +export PATH + +CLUSTER=who-need-help +MARKER="$ROOT/.tools/${CLUSTER}.owned" + +if kind get clusters | grep -Fxq "$CLUSTER"; then + if [ ! -f "$MARKER" ]; then + echo "A kind cluster named '$CLUSTER' already exists but was not created by this project." >&2 + echo "Refusing to modify it. Rename/remove it yourself or inspect it first." >&2 + exit 1 + fi +else + kind create cluster --config "$ROOT/deploy/kind/cluster.yaml" + touch "$MARKER" +fi + +docker build --tag who-need-help:local "$ROOT" +kind load docker-image who-need-help:local --name "$CLUSTER" + +kubectl create namespace who-need-help --dry-run=client --output=yaml | kubectl apply -f - +kubectl --namespace who-need-help apply -f "$ROOT/deploy/kind/dependencies.yaml" +kubectl --namespace who-need-help rollout status deployment/postgis +kubectl --namespace who-need-help rollout status deployment/mailpit + +helm upgrade --install who-need-help "$ROOT/deploy/helm/who-need-help" \ + --namespace who-need-help \ + --values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \ + --set-string app.codexSessionId="${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-not-configured}}" \ + --wait + +kubectl --namespace who-need-help rollout restart \ + deployment/who-need-help-who-need-help-web \ + deployment/who-need-help-who-need-help-worker +kubectl --namespace who-need-help rollout status deployment/who-need-help-who-need-help-web +kubectl --namespace who-need-help rollout status deployment/who-need-help-who-need-help-worker +"$ROOT/scripts/verify-realtime-cluster.sh" kind + +echo "Who Need Help: http://localhost:4011" +echo "Mailpit: http://localhost:8028" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..7a7cad5 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +docker compose -p who_need_help up -d --wait db +docker build --target test --tag who-need-help:test . + +exec docker run --rm \ + --network who_need_help_internal \ + --env MIX_ENV=test \ + --env DB_HOST=db \ + --env TEST_POOL_SIZE="${TEST_POOL_SIZE:-10}" \ + who-need-help:test \ + mix test diff --git a/scripts/verify-realtime-cluster.sh b/scripts/verify-realtime-cluster.sh new file mode 100755 index 0000000..8d8a133 --- /dev/null +++ b/scripts/verify-realtime-cluster.sh @@ -0,0 +1,88 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +MODE=${1:-compose} + +case "$MODE" in + compose) + TARGET=$(docker compose -p who_need_help ps -q web | head -n 1) + if [ -z "$TARGET" ]; then + echo "No running Compose web replica was found." >&2 + exit 1 + fi + RUN="docker exec $TARGET" + ;; + kind) + PATH="$ROOT/.tools/bin:$PATH" + export PATH + TARGET=$( + kubectl --context kind-who-need-help --namespace who-need-help get pods \ + -l app.kubernetes.io/component=web \ + -o jsonpath='{.items[0].metadata.name}' + ) + if [ -z "$TARGET" ]; then + echo "No running kind web replica was found." >&2 + exit 1 + fi + RUN="kubectl --context kind-who-need-help --namespace who-need-help exec $TARGET --" + ;; + *) + echo "Usage: $0 [compose|kind]" >&2 + exit 1 + ;; +esac + +# The subscriber runs on the selected web node. The broadcast is executed by a +# different connected BEAM node. Success proves distributed PubSub fan-out. +$RUN /app/bin/who_need_help rpc ' + live_peers = + Enum.filter(Node.list(), fn peer -> + case :rpc.call(peer, Process, :whereis, [WhoNeedHelp.PubSub]) do + pid when is_pid(pid) -> true + _ -> false + end + end) + + case live_peers do + [] -> + exit({:no_live_cluster_peers, Node.list()}) + + peers -> + topic = "cluster:verify:" <> Integer.to_string(System.unique_integer([:positive])) + :ok = Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, topic) + + result = + Enum.reduce_while(peers, nil, fn peer, _result -> + case :rpc.call(peer, Phoenix.PubSub, :broadcast, [ + WhoNeedHelp.PubSub, + topic, + {:cross_replica_probe, peer} + ]) do + :ok -> + receive do + {:cross_replica_probe, ^peer} -> + {:halt, + %{ + status: :ok, + subscriber: node(), + broadcaster: peer, + peers: Node.list() + }} + after + 5_000 -> {:cont, nil} + end + + _error -> + {:cont, nil} + end + end) + + result = + result || + %{status: :timeout, subscriber: node(), attempted_peers: peers, peers: Node.list()} + + IO.inspect(result) + if result.status != :ok, do: exit({:cross_replica_pubsub_failed, result}) + end +' diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..a1eae6f --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,79 @@ +defmodule WhoNeedHelpWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use WhoNeedHelpWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint WhoNeedHelpWeb.Endpoint + + use WhoNeedHelpWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import WhoNeedHelpWeb.ConnCase + end + end + + setup tags do + WhoNeedHelp.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn} = context) do + user = WhoNeedHelp.AccountsFixtures.user_fixture() + scope = WhoNeedHelp.Accounts.Scope.for_user(user) + + opts = + context + |> Map.take([:token_authenticated_at]) + |> Enum.into([]) + + %{conn: log_in_user(conn, user, opts), user: user, scope: scope} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user, opts \\ []) do + token = WhoNeedHelp.Accounts.generate_user_session_token(user) + + maybe_set_token_authenticated_at(token, opts[:token_authenticated_at]) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end + + defp maybe_set_token_authenticated_at(_token, nil), do: nil + + defp maybe_set_token_authenticated_at(token, authenticated_at) do + WhoNeedHelp.AccountsFixtures.override_token_authenticated_at(token, authenticated_at) + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..8f1ad7f --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule WhoNeedHelp.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use WhoNeedHelp.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias WhoNeedHelp.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import WhoNeedHelp.DataCase + end + end + + setup tags do + WhoNeedHelp.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(WhoNeedHelp.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..f87a965 --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,91 @@ +defmodule WhoNeedHelp.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `WhoNeedHelp.Accounts` context. + """ + + import Ecto.Query + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.Scope + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email(), + display_name: "Helpful neighbor", + terms_accepted: true + }) + end + + def unconfirmed_user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Accounts.register_user() + + user + end + + def user_fixture(attrs \\ %{}) do + user = unconfirmed_user_fixture(attrs) + + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, {user, _expired_tokens}} = + Accounts.login_user_by_magic_link(token) + + user + end + + def user_scope_fixture do + user = user_fixture() + user_scope_fixture(user) + end + + def user_scope_fixture(user) do + Scope.for_user(user) + end + + def set_password(user) do + {:ok, {user, _expired_tokens}} = + Accounts.update_user_password(user, %{password: valid_user_password()}) + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end + + def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do + WhoNeedHelp.Repo.update_all( + from(t in Accounts.UserToken, + where: t.token == ^token + ), + set: [authenticated_at: authenticated_at] + ) + end + + def generate_user_magic_link_token(user) do + {encoded_token, user_token} = Accounts.UserToken.build_email_token(user, "login") + WhoNeedHelp.Repo.insert!(user_token) + {encoded_token, user_token.token} + end + + def offset_user_token(token, amount_to_add, unit) do + dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit) + + WhoNeedHelp.Repo.update_all( + from(ut in Accounts.UserToken, where: ut.token == ^token), + set: [inserted_at: dt, authenticated_at: dt] + ) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..cde42eb --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(WhoNeedHelp.Repo, :manual) diff --git a/test/who_need_help/accounts_test.exs b/test/who_need_help/accounts_test.exs new file mode 100644 index 0000000..4673684 --- /dev/null +++ b/test/who_need_help/accounts_test.exs @@ -0,0 +1,397 @@ +defmodule WhoNeedHelp.AccountsTest do + use WhoNeedHelp.DataCase + + alias WhoNeedHelp.Accounts + + import WhoNeedHelp.AccountsFixtures + alias WhoNeedHelp.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() |> set_password() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() |> set_password() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!("11111111-1111-1111-1111-111111111111") + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{email: ["can't be blank"]} = errors_on(changeset) + end + + test "validates email when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum values for email for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the uppercased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users without password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_nil(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "sudo_mode?/2" do + test "validates the authenticated_at time" do + now = DateTime.utc_now() + + assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()}) + assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)}) + refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)}) + + # minute override + refute Accounts.sudo_mode?( + %User{authenticated_at: DateTime.add(now, -11, :minute)}, + -10 + ) + + # not authenticated + refute Accounts.sudo_mode?(%User{}) + end + end + + describe "change_user_email/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = unconfirmed_user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert {:ok, %{email: ^email}} = Accounts.update_user_email(user, token) + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + + assert Accounts.update_user_email(user, token) == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password( + %User{}, + %{ + "password" => "new valid password" + }, + hash_password: false + ) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, {user, expired_tokens}} = + Accounts.update_user_password(user, %{ + password: "new valid password" + }) + + assert expired_tokens == [] + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, {_, _}} = + Accounts.update_user_password(user, %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + assert user_token.authenticated_at != nil + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + + test "duplicates the authenticated_at of given user in new token", %{user: user} do + user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)} + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.authenticated_at == user.authenticated_at + assert DateTime.compare(user_token.inserted_at, user.authenticated_at) == :gt + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert {session_user, token_inserted_at} = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + assert session_user.authenticated_at != nil + assert token_inserted_at != nil + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + dt = ~N[2020-01-01 00:00:00] + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "get_user_by_magic_link_token/1" do + setup do + user = user_fixture() + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + %{user: user, token: encoded_token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_magic_link_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_magic_link_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_magic_link_token(token) + end + end + + describe "login_user_by_magic_link/1" do + test "confirms user and expires tokens" do + user = unconfirmed_user_fixture() + refute user.confirmed_at + {encoded_token, hashed_token} = generate_user_magic_link_token(user) + + assert {:ok, {user, [%{token: ^hashed_token}]}} = + Accounts.login_user_by_magic_link(encoded_token) + + assert user.confirmed_at + end + + test "returns user and (deleted) token for confirmed user" do + user = user_fixture() + assert user.confirmed_at + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + assert {:ok, {^user, []}} = Accounts.login_user_by_magic_link(encoded_token) + # one time use only + assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token) + end + + test "raises when unconfirmed user has password set" do + user = unconfirmed_user_fixture() + {1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"]) + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + + assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn -> + Accounts.login_user_by_magic_link(encoded_token) + end + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_login_instructions/2" do + setup do + %{user: unconfirmed_user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "login" + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs new file mode 100644 index 0000000..802021d --- /dev/null +++ b/test/who_need_help/mutual_aid_flow_test.exs @@ -0,0 +1,164 @@ +defmodule WhoNeedHelp.MutualAidFlowTest do + use WhoNeedHelp.DataCase, async: false + + import WhoNeedHelp.AccountsFixtures + + alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust} + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Tracking.Position + + setup do + category = Catalog.seed_defaults() + requester = user_fixture(display_name: "Requester") + helper = user_fixture(display_name: "Helper") + + request_attrs = %{ + "title" => "Medicine is ready at the pharmacy", + "description" => "The legal medicine is already reserved and paid for.", + "pickup_instructions" => "Ask for order WNH, no payment is required.", + "location_label" => "Central district", + "latitude" => "50.4501", + "longitude" => "30.5234", + "urgency" => "now", + "location_visibility" => "approximate_public", + "structured_data" => %{"pickup_status" => "reserved"}, + "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), + "category_id" => category.id + } + + {:ok, + category: category, + requester: requester, + helper: helper, + requester_scope: user_scope_fixture(requester), + helper_scope: user_scope_fixture(helper), + request_attrs: request_attrs} + end + + test "requester and helper complete a verified handover", context do + {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) + {:error, :own_request} = Help.accept_request(context.requester_scope, request.id) + {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) + {:ok, assignment} = Help.start_assignment(context.helper_scope, assignment.id) + + assert assignment.status == :in_progress + + {:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id) + {:ok, _} = Help.confirm_completion(context.helper_scope, assignment.id) + + {:ok, assignment} = + Help.verify_handover(context.helper_scope, assignment.id, Help.handover_code(request.id)) + + assert assignment.status == :completed + assert Help.get_request!(request.id).status == :completed + + reputation = Trust.reputation(context.helper.id) + assert reputation.completed == 1 + assert reputation.unique_people == 1 + assert reputation.verified_handovers == 1 + end + + test "chat is durable and only visible to match participants", context do + outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture() + {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) + {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) + + {:ok, message} = + Messaging.send_message(context.helper_scope, assignment, %{ + "body" => "I am on my way.", + "assignment_id" => Ecto.UUID.generate(), + "sender_id" => context.requester.id + }) + + assert message.body == "I am on my way." + assert message.assignment_id == assignment.id + assert message.sender_id == context.helper.id + + assert [%{body: "I am on my way."}] = + Messaging.list_messages(context.requester_scope, assignment) + + assert [] == Messaging.list_messages(outsider_scope, assignment) + + assert {:error, :forbidden} = + Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"}) + end + + test "stopping tracking deletes the exact current position", context do + {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) + {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) + {:ok, _session} = Tracking.start_session(context.helper_scope, assignment) + + {:ok, position} = + Tracking.update_position(context.helper_scope, assignment, %{ + "latitude" => 50.45, + "longitude" => 30.52, + "accuracy_meters" => 12.0, + "tracking_session_id" => Ecto.UUID.generate() + }) + + assert Repo.get(Position, position.id) + assert {:ok, :stopped} = Tracking.stop_session(context.helper_scope, assignment) + refute Repo.get(Position, position.id) + end + + test "reviews remain hidden until both participants submit", context do + {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) + {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) + {:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id) + {:ok, _} = Help.confirm_completion(context.helper_scope, assignment.id) + + {:ok, assignment} = + Help.verify_handover(context.helper_scope, assignment.id, Help.handover_code(request.id)) + + {:ok, _} = + Trust.submit_review(context.requester_scope, assignment, %{ + "rating" => 5, + "comment" => "Kind" + }) + + assert Trust.visible_reviews(context.helper.id) == [] + + {:ok, _} = + Trust.submit_review(context.helper_scope, assignment, %{"rating" => 5, "comment" => "Clear"}) + + assert [%{rating: 5}] = Trust.visible_reviews(context.helper.id) + assert [%{rating: 5}] = Trust.visible_reviews(context.requester.id) + end + + test "category moderation export excludes proposer identity", context do + {:ok, proposal} = + Catalog.propose(context.requester_scope, %{ + "proposed_name" => "Bicycle repair", + "reason" => "A reusable category for urgent roadside bicycle help." + }) + + export = CatalogModeration.export_open_proposals() + decoded = Jason.decode!(export) + [proposal_export] = decoded["proposals"] + + assert proposal_export["proposal_id"] == proposal.id + assert proposal_export["proposed_name"] == "Bicycle repair" + assert proposal_export["community_votes"] == 0 + + refute export =~ context.requester.email + refute export =~ context.requester.display_name + end + + test "manual social links stay unverified and owner-controlled", context do + {:ok, identity} = + Accounts.add_social_identity(context.requester, %{ + "provider" => "telegram", + "profile_url" => "https://t.me/helpful_neighbor", + "handle" => "@helpful_neighbor", + "verified_at" => DateTime.utc_now(:second), + "provider_uid" => "must-not-be-user-controlled" + }) + + assert is_nil(identity.verified_at) + assert is_nil(identity.provider_uid) + assert [listed] = Accounts.list_social_identities(context.requester) + assert listed.id == identity.id + assert {:error, :not_found} = Accounts.delete_social_identity(context.helper, identity.id) + assert {:ok, _identity} = Accounts.delete_social_identity(context.requester, identity.id) + end +end diff --git a/test/who_need_help/trust_safety_test.exs b/test/who_need_help/trust_safety_test.exs new file mode 100644 index 0000000..9edd1cf --- /dev/null +++ b/test/who_need_help/trust_safety_test.exs @@ -0,0 +1,290 @@ +defmodule WhoNeedHelp.TrustSafetyTest do + use WhoNeedHelp.DataCase, async: false + + import WhoNeedHelp.AccountsFixtures + + alias WhoNeedHelp.{Catalog, Help, Messaging, Release, Tracking, Trust} + alias WhoNeedHelp.Help.Assignment + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Tracking.Position + alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, RateLimiter} + + setup do + category = Catalog.seed_defaults() + requester = user_fixture(display_name: "Requester") + helper = user_fixture(display_name: "Helper") + + attrs = %{ + "title" => "Medicine is ready at the pharmacy", + "description" => "The legal medicine is already reserved and ready for pickup.", + "location_label" => "Central district", + "latitude" => "50.4501", + "longitude" => "30.5234", + "urgency" => "now", + "location_visibility" => "approximate_public", + "structured_data" => %{"pickup_status" => "reserved"}, + "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), + "category_id" => category.id + } + + %{ + category: category, + requester: requester, + helper: helper, + requester_scope: user_scope_fixture(requester), + helper_scope: user_scope_fixture(helper), + attrs: attrs + } + end + + test "a block removes discovery and prevents matching and new chat", context do + {:ok, request} = Help.create_request(context.requester_scope, context.attrs) + assert Enum.any?(Help.list_open_requests(context.helper_scope), &(&1.id == request.id)) + + assert {:ok, _block} = Trust.block(context.helper_scope, context.requester.id) + refute Enum.any?(Help.list_open_requests(context.helper_scope), &(&1.id == request.id)) + assert {:error, :blocked} = Help.accept_request(context.helper_scope, request.id) + + assert {:ok, :unblocked} = Trust.unblock(context.helper_scope, context.requester.id) + assert {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) + + assert {:ok, _block} = Trust.block(context.requester_scope, context.helper.id) + + assert {:error, :blocked} = + Messaging.send_message(context.helper_scope, assignment, %{"body" => "Hello"}) + + assert [] = Messaging.list_messages(context.requester_scope, assignment) + end + + test "tracking derives movement and proximity from browser accuracy envelopes", context do + {:ok, request} = Help.create_request(context.requester_scope, context.attrs) + {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) + {:ok, _} = Tracking.start_session(context.helper_scope, assignment) + {:ok, _} = Tracking.start_session(context.requester_scope, assignment) + + {:ok, _} = + Tracking.update_position(context.helper_scope, assignment, %{ + "latitude" => 50.4500, + "longitude" => 30.5200, + "accuracy_meters" => 5.0 + }) + + {:ok, _} = + Tracking.update_position(context.helper_scope, assignment, %{ + "latitude" => 50.4510, + "longitude" => 30.5200, + "accuracy_meters" => 5.0 + }) + + {:ok, _} = + Tracking.update_position(context.requester_scope, assignment, %{ + "latitude" => 50.4511, + "longitude" => 30.5200, + "accuracy_meters" => 20.0 + }) + + assignment = Repo.get!(Assignment, assignment.id) + assert assignment.helper_movement_observed_at + assert assignment.proximity_observed_at + end + + test "leaderboard primary counters use unique counterparts", context do + first = complete_supported_help(context, context.attrs) + second_attrs = Map.put(context.attrs, "title", "A second pharmacy pickup is ready") + second = complete_supported_help(context, second_attrs) + + [entry] = Enum.filter(Trust.leaderboard(), &(&1.user.id == context.helper.id)) + assert entry.completed == 2 + assert entry.unique_people == 1 + assert entry.verified_people == 1 + assert entry.location_supported_people == 1 + + assert Repo.exists?( + from signal in AbuseSignal, + where: signal.assignment_id == ^second.id and signal.kind == :repeated_pair + ) + + refute first.id == second.id + end + + test "reports expose only linked evidence to moderators and audit that access", context do + moderator = + user_fixture(display_name: "Moderator") + |> Ecto.Changeset.change(role: :moderator) + |> Repo.update!() + + moderator_scope = user_scope_fixture(moderator) + {:ok, request} = Help.create_request(context.requester_scope, context.attrs) + {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) + + {:ok, _message} = + Messaging.send_message(context.helper_scope, assignment, %{"body" => "Pickup update"}) + + {:ok, report} = + Trust.report(context.requester_scope, %{ + "reason" => "harassment", + "details" => "This conversation needs a moderator review.", + "assignment_id" => assignment.id, + "reporter_id" => context.helper.id + }) + + assert report.reporter_id == context.requester.id + assert [listed] = Trust.list_reports(moderator_scope, :open) + assert listed.id == report.id + + assert {:ok, %{messages: [%{body: "Pickup update"}]}} = + Trust.report_evidence(moderator_scope, report.id) + + assert Repo.exists?( + from event in AuditEvent, + where: + event.action == "report.evidence_viewed" and event.target_id == ^report.id and + event.actor_id == ^moderator.id + ) + end + + test "medicine category structured fields are enforced by the server", context do + invalid = Map.delete(context.attrs, "structured_data") + assert {:error, changeset} = Help.create_request(context.requester_scope, invalid) + assert "pickup_status is required" in errors_on(changeset).structured_data + + invalid = + Map.put(context.attrs, "structured_data", %{"pickup_status" => "not-an-option"}) + + assert {:error, changeset} = Help.create_request(context.requester_scope, invalid) + assert "pickup_status has an invalid value" in errors_on(changeset).structured_data + end + + test "configured rate limits are atomic database counters", context do + old = Application.get_env(:who_need_help, :rate_limit_policies) + + Application.put_env(:who_need_help, :rate_limit_policies, %{ + "test_action" => %{"limit" => 1, "window_seconds" => 60} + }) + + on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end) + + assert {:ok, %{count: 1}} = RateLimiter.check(:test_action, context.helper.id) + assert {:error, :rate_limited} = RateLimiter.check(:test_action, context.helper.id) + assert {:ok, %{count: 1}} = RateLimiter.check(:test_action, context.requester.id) + + assert {:error, :rate_limited} = + Trust.authorize_action(context.helper_scope, :test_action) + + assert Repo.exists?( + from signal in AbuseSignal, + where: signal.subject_id == ^context.helper.id and signal.kind == :velocity + ) + end + + test "restricted accounts cannot perform trust-sensitive actions", context do + context.helper + |> WhoNeedHelp.Accounts.User.moderation_changeset(%{moderation_status: :restricted}) + |> Repo.update!() + + assert {:error, :account_not_eligible} = + Help.create_request(context.helper_scope, context.attrs) + end + + test "hidden request coordinates are absent publicly but exact for a participant", context do + hidden_attrs = Map.put(context.attrs, "location_visibility", "hidden") + {:ok, request} = Help.create_request(context.requester_scope, hidden_attrs) + request = Help.get_request!(request.id) + outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture() + + assert is_nil(WhoNeedHelp.Help.HelpRequest.public_coordinates(request)) + assert is_nil(Help.request_coordinates(outsider_scope, request)) + + assert %{latitude: 50.4501, longitude: 30.5234, exact: true} = + Help.request_coordinates(context.requester_scope, request) + end + + test "cancelling a matched request ends tracking and deletes raw coordinates", context do + {:ok, request} = Help.create_request(context.requester_scope, context.attrs) + {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) + {:ok, _session} = Tracking.start_session(context.helper_scope, assignment) + + {:ok, position} = + Tracking.update_position(context.helper_scope, assignment, %{ + "latitude" => 50.45, + "longitude" => 30.52, + "accuracy_meters" => 10.0 + }) + + assert Repo.get(Position, position.id) + assert {:ok, _request} = Help.cancel_request(context.requester_scope, request.id) + refute Repo.get(Position, position.id) + assert Repo.get!(Assignment, assignment.id).status == :cancelled + end + + test "only an administrator can change roles and the last admin is protected", context do + moderator = + user_fixture(display_name: "Moderator") + |> Ecto.Changeset.change(role: :moderator) + |> Repo.update!() + + admin = + user_fixture(display_name: "Administrator") + |> Ecto.Changeset.change(role: :admin) + |> Repo.update!() + + assert {:error, :forbidden} = + Trust.moderate_role(user_scope_fixture(moderator), context.helper.id, %{ + "role" => "moderator" + }) + + assert {:ok, promoted} = + Trust.moderate_role(user_scope_fixture(admin), context.helper.id, %{ + "role" => "moderator" + }) + + assert promoted.role == :moderator + + assert {:error, :last_admin} = + Trust.moderate_role(user_scope_fixture(admin), admin.id, %{"role" => "user"}) + + assert Repo.exists?( + from event in AuditEvent, + where: + event.actor_id == ^admin.id and event.target_id == ^context.helper.id and + event.action == "user.role_changed" + ) + end + + test "the first administrator bootstrap is one-time and audited", context do + assert {:ok, admin} = Release.bootstrap_admin(context.helper.email) + assert admin.id == context.helper.id + assert admin.role == :admin + + assert {:error, :admin_already_exists} = + Release.bootstrap_admin(context.requester.email) + + assert Repo.exists?( + from event in AuditEvent, + where: + is_nil(event.actor_id) and event.target_id == ^context.helper.id and + event.action == "user.admin_bootstrapped" + ) + end + + defp complete_supported_help(context, attrs) do + {:ok, request} = Help.create_request(context.requester_scope, attrs) + {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) + + assignment = + assignment + |> Assignment.changeset(%{ + proximity_observed_at: DateTime.utc_now(:second), + helper_movement_observed_at: DateTime.utc_now(:second) + }) + |> Repo.update!() + + {:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id) + {:ok, _} = Help.confirm_completion(context.helper_scope, assignment.id) + + {:ok, assignment} = + Help.verify_handover(context.helper_scope, assignment.id, Help.handover_code(request.id)) + + assignment + end +end diff --git a/test/who_need_help_web/controllers/error_html_test.exs b/test/who_need_help_web/controllers/error_html_test.exs new file mode 100644 index 0000000..8d6828d --- /dev/null +++ b/test/who_need_help_web/controllers/error_html_test.exs @@ -0,0 +1,15 @@ +defmodule WhoNeedHelpWeb.ErrorHTMLTest do + use WhoNeedHelpWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template, only: [render_to_string: 4] + + test "renders 404.html" do + assert render_to_string(WhoNeedHelpWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(WhoNeedHelpWeb.ErrorHTML, "500", "html", []) == + "Internal Server Error" + end +end diff --git a/test/who_need_help_web/controllers/error_json_test.exs b/test/who_need_help_web/controllers/error_json_test.exs new file mode 100644 index 0000000..b4d828d --- /dev/null +++ b/test/who_need_help_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule WhoNeedHelpWeb.ErrorJSONTest do + use WhoNeedHelpWeb.ConnCase, async: true + + test "renders 404" do + assert WhoNeedHelpWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert WhoNeedHelpWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/who_need_help_web/controllers/page_controller_test.exs b/test/who_need_help_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..d3c92d6 --- /dev/null +++ b/test/who_need_help_web/controllers/page_controller_test.exs @@ -0,0 +1,27 @@ +defmodule WhoNeedHelpWeb.PageControllerTest do + use WhoNeedHelpWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Help can be closer than you think." + + assert html_response(conn, 200) =~ + ~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png") + end + + test "GET / selects Russian locale", %{conn: conn} do + conn = get(conn, ~p"/?locale=ru") + assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется." + end + + test "GET /safety", %{conn: conn} do + conn = get(conn, ~p"/safety") + assert html_response(conn, 200) =~ "Safety rules" + assert html_response(conn, 200) =~ "Not for emergencies" + end + + test "GET /manifest.webmanifest", %{conn: conn} do + conn = get(conn, "/manifest.webmanifest") + assert response(conn, 200) =~ ~s("short_name": "Who Need Help") + end +end diff --git a/test/who_need_help_web/controllers/user_registration_controller_test.exs b/test/who_need_help_web/controllers/user_registration_controller_test.exs new file mode 100644 index 0000000..80b5470 --- /dev/null +++ b/test/who_need_help_web/controllers/user_registration_controller_test.exs @@ -0,0 +1,50 @@ +defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do + use WhoNeedHelpWeb.ConnCase, async: true + + import WhoNeedHelp.AccountsFixtures + + describe "GET /users/register" do + test "renders registration page", %{conn: conn} do + conn = get(conn, ~p"/users/register") + response = html_response(conn, 200) + assert response =~ "Register" + assert response =~ ~p"/users/log-in" + assert response =~ ~p"/users/register" + end + + test "redirects if already logged in", %{conn: conn} do + conn = conn |> log_in_user(user_fixture()) |> get(~p"/users/register") + + assert redirected_to(conn) == ~p"/" + end + end + + describe "POST /users/register" do + @tag :capture_log + test "creates account but does not log in", %{conn: conn} do + email = unique_user_email() + + conn = + post(conn, ~p"/users/register", %{ + "user" => valid_user_attributes(email: email) + }) + + refute get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/users/log-in" + + assert conn.assigns.flash["info"] =~ + ~r/An email was sent to .*, please access it to confirm your account/ + end + + test "render errors for invalid data", %{conn: conn} do + conn = + post(conn, ~p"/users/register", %{ + "user" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "Register" + assert response =~ "must have the @ sign and no spaces" + end + end +end diff --git a/test/who_need_help_web/controllers/user_session_controller_test.exs b/test/who_need_help_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..60e87bc --- /dev/null +++ b/test/who_need_help_web/controllers/user_session_controller_test.exs @@ -0,0 +1,220 @@ +defmodule WhoNeedHelpWeb.UserSessionControllerTest do + use WhoNeedHelpWeb.ConnCase, async: true + + import WhoNeedHelp.AccountsFixtures + alias WhoNeedHelp.Accounts + + setup do + %{unconfirmed_user: unconfirmed_user_fixture(), user: user_fixture()} + end + + describe "GET /users/log-in" do + test "renders login page", %{conn: conn} do + conn = get(conn, ~p"/users/log-in") + response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ ~p"/users/register" + assert response =~ "Log in with email" + end + + test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do + html = + conn + |> log_in_user(user) + |> get(~p"/users/log-in") + |> html_response(200) + + assert html =~ "You need to reauthenticate" + refute html =~ "Register" + assert html =~ "Log in with email" + + assert html =~ + ~s( + Accounts.deliver_login_instructions(user, url) + end) + + conn = get(conn, ~p"/users/log-in/#{token}") + assert html_response(conn, 200) =~ "Confirm and stay logged in" + end + + test "renders login page for confirmed user", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + conn = get(conn, ~p"/users/log-in/#{token}") + html = html_response(conn, 200) + refute html =~ "Confirm my account" + assert html =~ "Log in" + end + + test "raises error for invalid token", %{conn: conn} do + conn = get(conn, ~p"/users/log-in/invalid-token") + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Magic link is invalid or it has expired." + end + end + + describe "POST /users/log-in - email and password" do + test "logs the user in", %{conn: conn, user: user} do + user = set_password(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log-out" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + user = set_password(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_who_need_help_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + user = set_password(user) + + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log-in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "emits error message with invalid credentials", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log-in?mode=password", %{ + "user" => %{"email" => user.email, "password" => "invalid_password"} + }) + + response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ "Invalid email or password" + end + end + + describe "POST /users/log-in - magic link" do + test "sends magic link email when user exists", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"email" => user.email} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert WhoNeedHelp.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login" + end + + test "logs the user in", %{conn: conn, user: user} do + {token, _hashed_token} = generate_user_magic_link_token(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => token} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log-out" + end + + test "confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do + {token, _hashed_token} = generate_user_magic_link_token(user) + refute user.confirmed_at + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => token}, + "_action" => "confirmed" + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully." + + assert Accounts.get_user!(user.id).confirmed_at + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log-out" + end + + test "emits error message when magic link is invalid", %{conn: conn} do + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => "invalid"} + }) + + assert html_response(conn, 200) =~ "The link is invalid or it has expired." + end + end + + describe "DELETE /users/log-out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log-out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log-out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/who_need_help_web/controllers/user_settings_controller_test.exs b/test/who_need_help_web/controllers/user_settings_controller_test.exs new file mode 100644 index 0000000..c65d398 --- /dev/null +++ b/test/who_need_help_web/controllers/user_settings_controller_test.exs @@ -0,0 +1,148 @@ +defmodule WhoNeedHelpWeb.UserSettingsControllerTest do + use WhoNeedHelpWeb.ConnCase, async: true + + alias WhoNeedHelp.Accounts + import WhoNeedHelp.AccountsFixtures + + setup :register_and_log_in_user + + describe "GET /users/settings" do + test "renders settings page", %{conn: conn} do + conn = get(conn, ~p"/users/settings") + response = html_response(conn, 200) + assert response =~ "Settings" + end + + test "redirects if user is not logged in" do + conn = build_conn() + conn = get(conn, ~p"/users/settings") + assert redirected_to(conn) == ~p"/users/log-in" + end + + @tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute) + test "redirects if user is not in sudo mode", %{conn: conn} do + conn = get(conn, ~p"/users/settings") + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must re-authenticate to access this page." + end + end + + describe "PUT /users/settings (change password form)" do + test "updates the user password and resets tokens", %{conn: conn, user: user} do + new_password_conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_password", + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not update password on invalid data", %{conn: conn} do + old_password_conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_password", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(old_password_conn, 200) + assert response =~ "Settings" + assert response =~ "should be at least 12 character(s)" + assert response =~ "does not match password" + + assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) + end + end + + describe "PUT /users/settings (change email form)" do + @tag :capture_log + test "updates the user email", %{conn: conn, user: user} do + conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_email", + "user" => %{"email" => unique_user_email()} + }) + + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "A link to confirm your email" + + assert Accounts.get_user_by_email(user.email) + end + + test "does not update email on invalid data", %{conn: conn} do + conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_email", + "user" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "Settings" + assert response =~ "must have the @ sign and no spaces" + end + end + + describe "GET /users/settings/confirm-email/:token" do + setup %{user: user} do + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{token: token, email: email} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "Email changed successfully" + + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + conn = get(conn, ~p"/users/settings/confirm-email/oops") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + assert redirected_to(conn) == ~p"/users/log-in" + end + end +end diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs new file mode 100644 index 0000000..e664f8a --- /dev/null +++ b/test/who_need_help_web/live/mutual_aid_live_test.exs @@ -0,0 +1,181 @@ +defmodule WhoNeedHelpWeb.MutualAidLiveTest do + use WhoNeedHelpWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import WhoNeedHelp.AccountsFixtures + + alias WhoNeedHelp.{Catalog, Help, Messaging, Trust} + alias WhoNeedHelp.Repo + + setup :register_and_log_in_user + + test "leaderboard is available to an authenticated user", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/leaderboard") + assert html =~ "Helpers leaderboard" + assert html =~ "Repeated help between the same pair" + end + + test "new request form is driven by category structured fields", %{conn: conn} do + category = Catalog.seed_defaults() + {:ok, view, html} = live(conn, ~p"/requests/new") + assert html =~ "This is not an emergency service" + + html = + render_change(view, "validate", %{ + "help_request" => %{"category_id" => category.id} + }) + + assert html =~ "Medicine pickup status" + assert html =~ "help_request[structured_data][pickup_status]" + end + + test "new request form uses the profile location visibility default", %{ + conn: conn, + user: user + } do + user + |> Ecto.Changeset.change(location_visibility: :exact_for_active_match) + |> Repo.update!() + + {:ok, view, _html} = live(conn, ~p"/requests/new") + + assert has_element?( + view, + "#help_request_location_visibility option[value='exact_for_active_match'][selected]" + ) + end + + test "views opened before matching subscribe to the private chat after assignment", %{ + conn: requester_conn, + scope: requester_scope + } do + category = Catalog.seed_defaults() + helper = user_fixture(display_name: "Helper") + helper_conn = build_conn() |> log_in_user(helper) + + {:ok, request} = Help.create_request(requester_scope, request_attrs(category)) + {:ok, requester_view, _html} = live(requester_conn, ~p"/requests/#{request.id}") + {:ok, helper_view, _html} = live(helper_conn, ~p"/requests/#{request.id}") + + helper_view + |> element("button[phx-click='accept']") + |> render_click() + + assert render(requester_view) =~ "Private match chat" + assert render(helper_view) =~ "Private match chat" + assert has_element?(helper_view, "#message-form") + + html = + helper_view + |> form("form[phx-submit='send-message']", %{ + "message" => %{"body" => "I am on my way to the pharmacy."} + }) + |> render_submit() + + assert html =~ "I am on my way to the pharmacy." + assert_push_event(helper_view, "reset-message-form", %{id: "message-form"}) + assert render(requester_view) =~ "I am on my way to the pharmacy." + + send( + requester_view.pid, + {:position_updated, helper.id, + %{latitude: 50.452, longitude: 30.526, accuracy: 6, captured_at: DateTime.utc_now()}} + ) + + assert render(requester_view) =~ "50.452" + assert render(requester_view) =~ "30.526" + + send(requester_view.pid, {:tracking_stopped, helper.id}) + + refute render(requester_view) =~ "50.452" + + stop_live_view(requester_view) + stop_live_view(helper_view) + end + + test "regular users cannot enter moderation", %{conn: conn} do + assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation") + end + + test "moderator dashboard renders reports, signals, proposals, and accounts", %{ + conn: conn, + user: user + } do + user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() + category = Catalog.seed_defaults() + requester = user_fixture(display_name: "Requester") + helper = user_fixture(display_name: "Helper") + + attrs = request_attrs(category) + {:ok, request} = Help.create_request(user_scope_fixture(requester), attrs) + {:ok, assignment} = Help.accept_request(user_scope_fixture(helper), request.id) + + {:ok, _message} = + Messaging.send_message(user_scope_fixture(helper), assignment, %{ + "body" => "Evidence message" + }) + + {:ok, _report} = + Trust.report(user_scope_fixture(requester), %{ + "reason" => "harassment", + "details" => "Please review the matched conversation.", + "assignment_id" => assignment.id + }) + + {:ok, _proposal} = + Catalog.propose(user_scope_fixture(requester), %{ + "proposed_name" => "Bicycle repair", + "reason" => "Urgent roadside bicycle repairs need a reusable category." + }) + + {:ok, view, html} = live(conn, ~p"/moderation") + assert html =~ "Moderation" + assert html =~ "Reports" + assert html =~ "Open abuse signals" + assert html =~ "Category proposals" + assert html =~ "Accounts" + assert html =~ "Please review the matched conversation." + assert html =~ "Bicycle repair" + + evidence_html = + view + |> element("button[phx-click='view-evidence']") + |> render_click() + + assert evidence_html =~ "Evidence message" + end + + test "request page exposes report and block controls for another user", %{conn: conn} do + category = Catalog.seed_defaults() + requester = user_fixture(display_name: "Requester") + + {:ok, request} = Help.create_request(user_scope_fixture(requester), request_attrs(category)) + {:ok, _view, html} = live(conn, ~p"/requests/#{request.id}") + + assert html =~ "Safety controls" + assert html =~ "Block this user" + assert html =~ "Send report" + assert html =~ "This is not an emergency service" + end + + defp request_attrs(category) do + %{ + "title" => "Medicine is ready at the pharmacy", + "description" => "The legal medicine is already reserved and ready for pickup.", + "location_label" => "Central district", + "latitude" => "50.4501", + "longitude" => "30.5234", + "urgency" => "now", + "location_visibility" => "approximate_public", + "structured_data" => %{"pickup_status" => "reserved"}, + "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), + "category_id" => category.id + } + end + + defp stop_live_view(%Phoenix.LiveViewTest.View{ + proxy: {_ref, _topic, proxy_pid} + }) do + Phoenix.LiveViewTest.ClientProxy.stop(proxy_pid, :shutdown) + end +end diff --git a/test/who_need_help_web/user_auth_test.exs b/test/who_need_help_web/user_auth_test.exs new file mode 100644 index 0000000..6cf5d66 --- /dev/null +++ b/test/who_need_help_web/user_auth_test.exs @@ -0,0 +1,293 @@ +defmodule WhoNeedHelpWeb.UserAuthTest do + use WhoNeedHelpWeb.ConnCase, async: true + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.Scope + alias WhoNeedHelpWeb.UserAuth + + import WhoNeedHelp.AccountsFixtures + + @remember_me_cookie "_who_need_help_web_user_remember_me" + @remember_me_cookie_max_age 60 * 60 * 24 * 14 + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, WhoNeedHelpWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: %{user_fixture() | authenticated_at: DateTime.utc_now(:second)}, conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "keeps session when re-authenticating", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> put_session(:to_be_removed, "value") + |> UserAuth.log_in_user(user) + + assert get_session(conn, :to_be_removed) + end + + test "clears session when user does not match when re-authenticating", %{ + conn: conn, + user: user + } do + other_user = user_fixture() + + conn = + conn + |> assign(:current_scope, Scope.for_user(other_user)) + |> put_session(:to_be_removed, "value") + |> UserAuth.log_in_user(user) + + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :user_remember_me) == true + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == @remember_me_cookie_max_age + end + + test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :user_remember_me) == true + + conn = + conn + |> recycle() + |> Map.replace!(:secret_key_base, WhoNeedHelpWeb.Endpoint.config(:secret_key_base)) + |> fetch_cookies() + |> init_test_session(%{user_remember_me: true}) + + # the conn is already logged in and has the remember_me cookie set, + # now we log in again and even without explicitly setting remember_me, + # the cookie should be set again + conn = conn |> UserAuth.log_in_user(user, %{}) + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == @remember_me_cookie_max_age + assert get_session(conn, :user_remember_me) == true + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_scope_for_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert get_session(conn, :user_token) == user_token + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert get_session(conn, :user_token) == user_token + assert get_session(conn, :user_remember_me) + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_scope_for_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_scope + end + + test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + offset_user_token(token, -10, :day) + {user, _} = Accounts.get_user_by_session_token(token) + + conn = + conn + |> put_session(:user_token, token) + |> put_session(:user_remember_me, true) + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert new_token = get_session(conn, :user_token) + assert new_token != token + assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert new_signed_token != signed_token + assert max_age == @remember_me_cookie_max_age + end + end + + describe "require_sudo_mode/2" do + test "allows users that have authenticated in the last 10 minutes", %{conn: conn, user: user} do + conn = + conn + |> fetch_flash() + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_sudo_mode([]) + + refute conn.halted + refute conn.status + end + + test "redirects when authentication is too old", %{conn: conn, user: user} do + eleven_minutes_ago = DateTime.utc_now(:second) |> DateTime.add(-11, :minute) + user = %{user | authenticated_at: eleven_minutes_ago} + user_token = Accounts.generate_user_session_token(user) + {user, token_inserted_at} = Accounts.get_user_by_session_token(user_token) + assert DateTime.compare(token_inserted_at, user.authenticated_at) == :gt + + conn = + conn + |> fetch_flash() + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_sudo_mode([]) + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must re-authenticate to access this page." + end + end + + describe "redirect_if_user_is_authenticated/2" do + setup %{conn: conn} do + %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} + end + + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.redirect_if_user_is_authenticated([]) + + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + setup %{conn: conn} do + %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} + end + + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_authenticated_user([]) + + refute conn.halted + refute conn.status + end + end +end