commit 7f5648452731afc1c67221430fd1f3ecac4fc7cd Author: SimpleTest Date: Sat Jul 18 16:52:16 2026 +0300 feat: implement Who Need Help MVP 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 0000000..b1b8ef5 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ 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 0000000..7f372bf Binary files /dev/null and b/priv/static/favicon.ico differ 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