fix: harden security concurrency and client boundaries

This commit is contained in:
SimpleTest 2026-07-20 16:18:15 +03:00
parent 05d06e059a
commit 2358772d25
80 changed files with 5200 additions and 3339 deletions

View File

@ -45,10 +45,28 @@ erl_crash.dump
/priv/static/assets/
/priv/static/cache_manifest.json
/android/.gradle/
/android/.kotlin/
/android/.idea/
/android/.cxx/
/android/app/build/
/android/build/
/android/dist/
/android/dist-*/
/android/local.properties
/android/*.apk
/android/*.aab
/android/*.jks
/android/*.keystore
/android/**/*.apk
/android/**/*.aab
/android/**/*.jks
/android/**/*.keystore
*.key
*.pem
*.p12
*.pfx
/.direnv/
/.envrc
/.tools/
/.playwright-cli/
/output/

View File

@ -29,8 +29,8 @@ WNH_BASE_URL=
# production capacity recommendations.
WNH_TRACKING_MIN_TIME_MS=5000
WNH_TRACKING_HTTP_TIMEOUT_MS=15000
WNH_ANDROID_TEST_API_MATRIX=30 34 37.0
WNH_ANDROID_TEST_DATA_PARTITION_SIZE=4G
WNH_ANDROID_TEST_API_MATRIX="24 30 34 37.0"
WNH_ANDROID_TEST_DATA_PARTITION_SIZE=1G
# 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

6
.gitignore vendored
View File

@ -67,3 +67,9 @@ __pycache__/
*.aab
*.jks
*.keystore
*.key
*.pem
*.p12
*.pfx
/.direnv/
/.envrc

View File

@ -315,7 +315,7 @@ is not recreated.
The Android device suite builds dedicated debug and instrumentation APKs,
boots a fresh emulator in an isolated container without external networking,
and serves its HTTP fixture only on device loopback. Run the default API 37
probe or the complete API 30/34/37 matrix:
probe or the complete minimum/current API 24/30/34/37 matrix:
```bash
./scripts/android-instrumentation-test.sh

View File

@ -1,6 +1,17 @@
.gradle
.kotlin
.idea
.cxx
app/build
build
dist
dist-*
local.properties
*.apk
*.aab
*.jks
*.keystore
**/*.apk
**/*.aab
**/*.jks
**/*.keystore

View File

@ -7,12 +7,17 @@ service with a persistent notification and Stop action, so it can continue
while the Activity is minimized without requesting Android's
background-location permission.
The native tracking bridge uses `WebViewCompat.addWebMessageListener` with the
exact configured origin and rejects messages outside the main frame. It does
not expose a legacy `addJavascriptInterface` object to every frame.
## Verified build configuration
- Android Gradle Plugin 9.3.0
- Gradle 9.6.1
- Android SDK Command-line Tools 22.0
- Android CLI 1.0.15857036 (embedded in the locked Command-line Tools archive)
- AndroidX WebKit 1.16.0
- compileSdk / targetSdk 37
- Build Tools 37.0.0
- Java source and bytecode level 17
@ -88,7 +93,7 @@ report.
## Automated device tests
Run the default API 37 suite or the complete API 30/34/37 matrix from the
Run the default API 37 suite or the complete API 24/30/34/37 matrix from the
repository root:
```sh

View File

@ -20,6 +20,9 @@ fun manifestOrigin(value: String): URI? =
(uri.scheme == "http" || uri.scheme == "https") && !uri.host.isNullOrBlank()
}
fun isOriginPath(uri: URI): Boolean =
uri.path.isNullOrEmpty() || uri.path == "/"
val debugManifestOrigin = manifestOrigin(debugBaseUrl.get())
val releaseManifestOrigin = manifestOrigin(releaseBaseUrl.get())
@ -119,6 +122,7 @@ tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.
uri.scheme != "https" ||
uri.host.isNullOrBlank() ||
uri.userInfo != null ||
!isOriginPath(uri) ||
uri.query != null ||
uri.fragment != null
) {
@ -142,6 +146,7 @@ tasks.matching { it.name == "preDebugBuild" }.configureEach {
(uri.scheme != "http" && uri.scheme != "https") ||
uri.host.isNullOrBlank() ||
uri.userInfo != null ||
!isOriginPath(uri) ||
uri.query != null ||
uri.fragment != null
) {
@ -177,6 +182,7 @@ tasks.withType<JavaCompile>().configureEach {
dependencies {
implementation("androidx.activity:activity:1.13.0")
implementation("androidx.webkit:webkit:1.16.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:core:1.7.0")
androidTestImplementation("androidx.test:runner:1.7.0")

View File

@ -11,11 +11,15 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.Manifest;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.SystemClock;
import android.service.notification.StatusBarNotification;
import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
@ -70,17 +74,21 @@ public final class AndroidClientInstrumentedTest {
assertFalse(hasLocationPermission());
try (ActivityScenario<MainActivity> scenario = launch("/permission-boundary")) {
scenario.onActivity(activity ->
activity.startForegroundService(
TrackingService.startIntent(
scenario.onActivity(activity -> {
Intent intent = TrackingService.startIntent(
activity,
UUID.randomUUID().toString(),
"csrf-without-permission",
"_session=without-permission"
)
)
);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(intent);
} else {
activity.startService(intent);
}
});
waitForServiceState(false);
assertFalse(
device.hasObject(By.text(context.getString(R.string.tracking_active)))
@ -99,7 +107,34 @@ public final class AndroidClientInstrumentedTest {
.withElement(findElement(Locator.ID, "location"))
.perform(webClick());
UiObject2 allow = device.wait(
UiObject2 allow = null;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
allow = device.wait(
Until.findObject(
By.res(
"com.android.packageinstaller",
"permission_allow_button"
)
),
UI_TIMEOUT_MS
);
}
if (allow == null && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
allow = device.wait(
Until.findObject(
By.res(
"com.google.android.packageinstaller",
"permission_allow_button"
)
),
UI_TIMEOUT_MS
);
}
if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
allow = device.wait(
Until.findObject(
By.res(
LOCATION_PERMISSION_CONTROLLER,
@ -108,8 +143,9 @@ public final class AndroidClientInstrumentedTest {
),
UI_TIMEOUT_MS
);
}
if (allow == null) {
if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
allow = device.wait(
Until.findObject(By.textContains("While using the app")),
UI_TIMEOUT_MS
@ -328,16 +364,20 @@ public final class AndroidClientInstrumentedTest {
ActivityScenario<MainActivity> scenario,
String assignmentId
) {
scenario.onActivity(activity ->
activity.startForegroundService(
TrackingService.startIntent(
scenario.onActivity(activity -> {
Intent intent = TrackingService.startIntent(
activity,
assignmentId,
"instrumentation-csrf",
"_session=instrumentation"
)
)
);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(intent);
} else {
activity.startService(intent);
}
});
}
private void openNotificationAndClickStop() {
@ -347,14 +387,59 @@ public final class AndroidClientInstrumentedTest {
UI_TIMEOUT_MS
);
assertNotNull("Foreground tracking notification was not visible", active);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
sendStopActionFromActiveNotification();
return;
}
UiObject2 stop = device.wait(
Until.findObject(By.text(context.getString(R.string.tracking_stop_action))),
UI_TIMEOUT_MS
);
assertNotNull("Foreground tracking notification had no Stop action", stop);
stop.click();
}
private void sendStopActionFromActiveNotification() {
NotificationManager manager = context.getSystemService(NotificationManager.class);
String expectedTitle = context.getString(R.string.tracking_stop_action);
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(UI_TIMEOUT_MS);
while (System.nanoTime() < deadline) {
for (StatusBarNotification statusBarNotification : manager.getActiveNotifications()) {
Notification.Action[] actions = statusBarNotification.getNotification().actions;
if (actions == null) {
continue;
}
for (Notification.Action action : actions) {
if (
action.title != null
&& expectedTitle.contentEquals(action.title)
&& action.actionIntent != null
) {
try {
action.actionIntent.send();
return;
} catch (PendingIntent.CanceledException error) {
throw new AssertionError(
"Foreground tracking Stop action was canceled",
error
);
}
}
}
}
SystemClock.sleep(50);
}
throw new AssertionError("Foreground tracking notification had no Stop action");
}
private boolean hasLocationPermission() {
return context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
== android.content.pm.PackageManager.PERMISSION_GRANTED

View File

@ -39,15 +39,19 @@ public final class TrackingProcessDeathProbeTest {
);
}
context.startForegroundService(
TrackingService.startIntent(
Intent intent = TrackingService.startIntent(
context,
UUID.randomUUID().toString(),
"process-death-csrf",
"_session=process-death"
)
);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent);
} else {
context.startService(intent);
}
// The host harness kills this exact process after observing the active
// service and notification. Reaching the deadline means the harness did
// not perform the required process-death probe.

View File

@ -15,7 +15,6 @@ import android.util.Log;
import android.view.ViewGroup;
import android.webkit.CookieManager;
import android.webkit.GeolocationPermissions;
import android.webkit.JavascriptInterface;
import android.webkit.SslErrorHandler;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
@ -30,10 +29,17 @@ import androidx.activity.ComponentActivity;
import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.webkit.WebMessageCompat;
import androidx.webkit.WebViewCompat;
import androidx.webkit.WebViewFeature;
import java.util.ArrayList;
import java.util.Collections;
import java.util.UUID;
import org.json.JSONException;
import org.json.JSONObject;
public final class MainActivity extends ComponentActivity {
private static final String LOG_TAG = "WhoNeedHelpWebView";
private WebView webView;
@ -106,7 +112,7 @@ public final class MainActivity extends ComponentActivity {
cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, false);
webView.addJavascriptInterface(new NativeTrackingBridge(), "WhoNeedHelpAndroid");
configureNativeTrackingBridge();
webView.setWebViewClient(new TrustedWebViewClient());
webView.setWebChromeClient(new LocationWebChromeClient());
getOnBackPressedDispatcher().addCallback(
@ -192,7 +198,6 @@ public final class MainActivity extends ComponentActivity {
if (webView != null) {
webView.stopLoading();
webView.removeJavascriptInterface("WhoNeedHelpAndroid");
webView.setWebChromeClient(null);
webView.setWebViewClient(null);
webView.destroy();
@ -275,6 +280,60 @@ public final class MainActivity extends ComponentActivity {
nativeTrackingPermissionLauncher.launch(permissions.toArray(new String[0]));
}
private void configureNativeTrackingBridge() {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
return;
}
WebViewCompat.addWebMessageListener(
webView,
"WhoNeedHelpAndroid",
Collections.singleton(trustedOrigin.originRule()),
(
WebView view,
WebMessageCompat message,
Uri sourceOrigin,
boolean isMainFrame,
androidx.webkit.JavaScriptReplyProxy replyProxy
) -> {
if (
!isMainFrame
|| sourceOrigin == null
|| !trustedOrigin.matchesOrigin(sourceOrigin.toString())
) {
return;
}
handleNativeTrackingMessage(message.getData());
}
);
}
private void handleNativeTrackingMessage(String payload) {
if (payload == null) {
dispatchNativeTrackingError();
return;
}
try {
JSONObject message = new JSONObject(payload);
String action = message.optString("action", "");
if ("start".equals(action)) {
prepareNativeTracking(
message.optString("assignment_id", ""),
message.optString("csrf_token", "")
);
} else if ("stop".equals(action)) {
stopService(new Intent(MainActivity.this, TrackingService.class));
} else {
dispatchNativeTrackingError();
}
} catch (JSONException exception) {
dispatchNativeTrackingError();
}
}
private void startPendingNativeTracking() {
PendingNativeTracking pending = pendingNativeTracking;
pendingNativeTracking = null;
@ -519,20 +578,6 @@ public final class MainActivity extends ComponentActivity {
}
}
private final class NativeTrackingBridge {
@JavascriptInterface
public void startTracking(String assignmentId, String csrfToken) {
runOnUiThread(() -> prepareNativeTracking(assignmentId, csrfToken));
}
@JavascriptInterface
public void stopTracking() {
runOnUiThread(() ->
stopService(new Intent(MainActivity.this, TrackingService.class))
);
}
}
private static final class PendingNativeTracking {
private final String assignmentId;
private final String csrfToken;

View File

@ -30,6 +30,7 @@ final class TrustedOrigin {
uri.getHost() == null ||
uri.getHost().trim().isEmpty() ||
uri.getUserInfo() != null ||
!pathIsOrigin(uri.getPath()) ||
uri.getQuery() != null ||
uri.getFragment() != null
) {
@ -43,6 +44,22 @@ final class TrustedOrigin {
return base.toString();
}
String originRule() {
try {
return new URI(
normalized(base.getScheme()),
null,
base.getHost(),
base.getPort(),
null,
null,
null
).toString();
} catch (URISyntaxException exception) {
throw new IllegalStateException("Trusted origin could not be normalized", exception);
}
}
boolean matches(String value) {
try {
URI candidate = new URI(value);

View File

@ -1,6 +1,7 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@ -16,6 +17,7 @@ public final class TrustedOriginTest {
assertFalse(origin.matches("https://help.example.evil.test/"));
assertFalse(origin.matches("http://help.example/"));
assertFalse(origin.matches("https://help.example:444/"));
assertEquals("https://help.example", origin.originRule());
}
@Test
@ -25,6 +27,7 @@ public final class TrustedOriginTest {
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"));
assertEquals("http://10.0.2.2:4010", origin.originRule());
}
@Test
@ -41,5 +44,9 @@ public final class TrustedOriginTest {
IllegalArgumentException.class,
() -> TrustedOrigin.parse("https://help.example?redirect=evil", false)
);
assertThrows(
IllegalArgumentException.class,
() -> TrustedOrigin.parse("https://help.example/app", false)
);
}
}

View File

@ -56,11 +56,46 @@ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
})
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const activateProtectedTokenFragment = () => {
if (!window.location.hash.startsWith("#token=")) return
const token = new URLSearchParams(window.location.hash.slice(1)).get("token")
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`)
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return
const candidates = [
{
form: document.getElementById("magic-link-fragment-form"),
input: document.getElementById("magic-link-fragment-token"),
options: document.getElementById("standard-login-options")
},
{
form: document.getElementById("email-change-fragment-form"),
input: document.getElementById("email-change-fragment-token")
}
]
const candidate = candidates.find(({form, input}) =>
form instanceof HTMLFormElement && input instanceof HTMLInputElement
)
if (!candidate) return
candidate.input.value = token
candidate.form.hidden = false
if (candidate.options instanceof HTMLElement) candidate.options.hidden = true
candidate.form.querySelector("button")?.focus()
}
activateProtectedTokenFragment()
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {
_csrf_token: csrfToken,
client_type: window.WhoNeedHelpAndroid ? "android" : "browser"
client_type: typeof window.WhoNeedHelpAndroid?.postMessage === "function" ? "android" : "browser"
},
hooks: {...colocatedHooks, ...Hooks},
})
@ -74,10 +109,14 @@ window.addEventListener("phx:reset-message-form", ({detail}) => {
if (form instanceof HTMLFormElement) form.reset()
})
window.addEventListener("phx:native-tracking-start", ({detail}) => {
window.WhoNeedHelpAndroid?.startTracking(detail.assignment_id, csrfToken)
window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({
action: "start",
assignment_id: detail.assignment_id,
csrf_token: csrfToken
}))
})
window.addEventListener("phx:native-tracking-stop", () => {
window.WhoNeedHelpAndroid?.stopTracking()
window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({action: "stop"}))
})
// connect if there are any LiveViews on the page

View File

@ -1,5 +1,7 @@
import maplibregl from "maplibre-gl"
const trackingMinTimeMs = 5000
const defaultStyle = {
version: 8,
sources: {
@ -134,8 +136,43 @@ export const Hooks = {
LiveTracking: {
mounted() {
this.destroying = false
this.lastLocationSentAt = 0
this.pendingLocation = null
this.locationTimer = undefined
if (window.WhoNeedHelpAndroid) {
this.sendLocation = payload => {
this.lastLocationSentAt = Date.now()
this.pendingLocation = null
this.pushEvent("location-update", payload)
}
this.queueLocation = payload => {
const elapsed = Date.now() - this.lastLocationSentAt
if (this.lastLocationSentAt === 0 || elapsed >= trackingMinTimeMs) {
if (this.locationTimer !== undefined) {
window.clearTimeout(this.locationTimer)
this.locationTimer = undefined
}
this.sendLocation(payload)
return
}
this.pendingLocation = payload
if (this.locationTimer === undefined) {
this.locationTimer = window.setTimeout(() => {
this.locationTimer = undefined
if (!this.destroying && this.pendingLocation) {
this.sendLocation(this.pendingLocation)
}
}, trackingMinTimeMs - elapsed)
}
}
if (typeof window.WhoNeedHelpAndroid?.postMessage === "function") {
this.nativeError = () => {
this.pushEvent("location-error", {})
this.pushEvent("stop-tracking", {})
@ -145,7 +182,7 @@ export const Hooks = {
}
this.watchId = navigator.geolocation.watchPosition(
position => this.pushEvent("location-update", {
position => this.queueLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy_meters: position.coords.accuracy
@ -158,6 +195,13 @@ export const Hooks = {
},
destroyed() {
this.destroying = true
this.pendingLocation = null
if (this.locationTimer !== undefined) {
window.clearTimeout(this.locationTimer)
this.locationTimer = undefined
}
if (this.nativeError) {
window.removeEventListener("wnh:native-tracking-error", this.nativeError)
}

View File

@ -13,6 +13,7 @@ x-boundary-app-environment: &boundary-app-environment
GITHUB_OAUTH_AUTHORIZE_URL: http://external-mock:8080/oauth/authorize
GITHUB_OAUTH_TOKEN_URL: http://external-mock:8080/oauth/token
GITHUB_OAUTH_USER_URL: http://external-mock:8080/oauth/user
ALLOW_INSECURE_EXTERNAL_HTTP: "true"
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS: "100"
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: "100"
SMTP_RELAY: external-mock

View File

@ -30,6 +30,7 @@ config :who_need_help,
app_role: :web,
codex_session_id: "not-configured",
e2e_routes: false,
secure_cookies: false,
rate_limit_policies: %{},
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
@ -37,6 +38,9 @@ config :who_need_help, WhoNeedHelp.Repo, types: WhoNeedHelp.PostgrexTypes
config :geo_postgis, json_library: Jason
config :phoenix,
filter_parameters: ["password", "token", "secret", "authorization", "cookie", "code"]
config :who_need_help, Oban,
repo: WhoNeedHelp.Repo,
queues: [maintenance: 2, push: 1],

View File

@ -19,6 +19,7 @@ e2e_routes =
end
config :who_need_help, :e2e_routes, e2e_routes
config :who_need_help, :secure_cookies, true
config :who_need_help, WhoNeedHelpWeb.Endpoint,
force_ssl: [

View File

@ -8,6 +8,42 @@ app_role =
other -> raise "APP_ROLE must be web, worker, or migrate; got #{inspect(other)}"
end
rate_limit_policies =
case System.get_env("RATE_LIMIT_POLICIES_JSON") do
value when value in [nil, ""] ->
%{}
json ->
case Jason.decode(json) do
{:ok, policies} when is_map(policies) ->
invalid_action =
Enum.find_value(policies, fn
{action, %{"limit" => limit, "window_seconds" => window}}
when is_binary(action) and action != "" and is_integer(limit) and limit > 0 and
is_integer(window) and window > 0 ->
nil
{action, _policy} ->
if is_binary(action) and action != "", do: action, else: "<invalid action>"
end)
if invalid_action do
raise """
RATE_LIMIT_POLICIES_JSON policy #{inspect(invalid_action)} must contain positive \
integer limit and window_seconds values.
"""
end
policies
{:ok, _other} ->
raise "RATE_LIMIT_POLICIES_JSON must be a JSON object."
{:error, _reason} ->
raise "RATE_LIMIT_POLICIES_JSON must contain valid JSON."
end
end
config :who_need_help,
app_role: app_role,
codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"),
@ -16,12 +52,7 @@ config :who_need_help,
"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)
rate_limit_policies: rate_limit_policies
optional_positive_integer = fn name ->
case System.get_env(name) do
@ -74,6 +105,13 @@ required_non_negative_integer = fn name ->
end
end
allow_insecure_external_http =
case System.get_env("ALLOW_INSECURE_EXTERNAL_HTTP", "false") do
"true" -> true
"false" -> false
other -> raise "ALLOW_INSECURE_EXTERNAL_HTTP must be true or false; got #{inspect(other)}"
end
oauth_endpoint = fn name, default ->
value =
case System.get_env(name) do
@ -83,11 +121,15 @@ oauth_endpoint = fn name, default ->
case URI.parse(value) do
%URI{scheme: scheme, host: host}
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
when is_binary(host) and host != "" and
(scheme == "https" or (scheme == "http" and allow_insecure_external_http)) ->
value
_other ->
raise "#{name} must be an absolute HTTP or HTTPS URL."
raise """
#{name} must be an absolute HTTPS URL. Plain HTTP is allowed only in the \
isolated external-boundary drill with ALLOW_INSECURE_EXTERNAL_HTTP=true.
"""
end
end
@ -155,11 +197,15 @@ push_configuration =
bearer_token != "" ->
case URI.parse(endpoint) do
%URI{scheme: scheme, host: host}
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
when is_binary(host) and host != "" and
(scheme == "https" or (scheme == "http" and allow_insecure_external_http)) ->
:ok
_other ->
raise "PUSH_HTTP_ENDPOINT must be an absolute HTTP or HTTPS URL."
raise """
PUSH_HTTP_ENDPOINT must be an absolute HTTPS URL. Plain HTTP is allowed only \
in the isolated external-boundary drill with ALLOW_INSECURE_EXTERNAL_HTTP=true.
"""
end
[

View File

@ -37,11 +37,13 @@ The Android module is a thin same-origin WebView shell with one native
foreground location service. 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, and private chat
use the same Phoenix application as the browser. A JavaScript bridge starts the
native service only from the visible Activity; the service posts the current
point through CSRF-protected, participant-authorized same-origin routes and
shows a persistent notification with Stop. Production signing and distribution
are separate operational work and are not represented as complete.
use the same Phoenix application as the browser. An origin-restricted
`WebViewCompat` message listener accepts tracking commands only from the trusted
main frame and starts the native service only from the visible Activity; the
service posts the current point through CSRF-protected,
participant-authorized same-origin routes and shows a persistent notification
with Stop. Production signing and distribution are separate operational work
and are not represented as complete.
## Application boundaries

View File

@ -96,6 +96,11 @@ network. It generates independent one-run OAuth and push credentials in an
ignored mode-`0600` environment file, builds the production release plus a
non-root standard-library Python protocol mock, and then verifies:
`compose.external-boundaries.yaml` is the only deployment file that sets
`ALLOW_INSECURE_EXTERNAL_HTTP=true`. Ordinary runtime configuration requires
HTTPS for OAuth and push endpoints so provider credentials are not sent over
plain HTTP.
1. GitHub-compatible OAuth authorization, PKCE S256, token exchange, normalized
user lookup, state mismatch, provider rejection, one-time-code replay,
a fresh flow after a temporary token error, and a token timeout;

View File

@ -47,12 +47,16 @@ test("registration, email confirmation, password, and email change work end to e
const emailChangeLink = await waitForApplicationEmailLink(
request,
changedEmail,
"/users/settings/confirm-email/",
"/users/settings/confirm-email",
);
recoverableOneTimeNavigations.add(emailChangeLink);
try {
await user.page.goto(emailChangeLink);
await expect(user.page.locator("#email-change-fragment-form")).toBeVisible();
await user.page
.getByRole("button", { name: "Confirm email change" })
.click();
await expect(user.page.getByText("Email changed successfully.")).toBeVisible();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

View File

@ -300,7 +300,7 @@ async function waitForMagicLink(
return waitForApplicationEmailLink(
request,
email,
"/users/log-in/",
"/users/log-in",
previousMessageID,
);
}
@ -325,7 +325,7 @@ export async function registerAndConfirm(
await expect(page.getByText(`An email was sent to ${email}`)).toBeVisible();
await gotoWithTransientRetry(page, await waitForMagicLink(request, email));
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
await expect(page.locator("#magic-link-fragment-form")).toBeVisible();
await page
.getByRole("button", { name: "Confirm and log in only this time" })
.click();

View File

@ -151,7 +151,7 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
"path" => "/requests/#{fixture.request.id}"
},
"assignment" => %{"id" => fixture.assignment.id},
"helper_login_path" => "/users/log-in/#{fixture.helper_login_token}"
"helper_login_path" => "/users/log-in#token=#{fixture.helper_login_token}"
}
context.manifest_path |> Path.dirname() |> File.mkdir_p!()
@ -350,7 +350,7 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
manifest["database"] == context.database and valid_users? and valid_request? and
valid_assignment? and is_binary(manifest["helper_login_path"]) and
String.starts_with?(manifest["helper_login_path"], "/users/log-in/") do
String.starts_with?(manifest["helper_login_path"], "/users/log-in#token=") do
Mix.raise("Android staging E2E manifest does not match the requested run and database")
end
end

View File

@ -61,7 +61,7 @@ defmodule WhoNeedHelp.Accounts do
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
Repo.get_by(User, email: String.trim(email))
end
@doc """
@ -78,7 +78,7 @@ defmodule WhoNeedHelp.Accounts do
"""
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)
user = get_user_by_email(email)
if User.valid_password?(user, password) and user.moderation_status != :suspended,
do: user
@ -381,9 +381,9 @@ defmodule WhoNeedHelp.Accounts do
Repo.transact(fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
%UserToken{sent_to: email} <- query |> lock("FOR UPDATE") |> Repo.one(),
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
{_count, _result} <-
{count, _result} when count > 0 <-
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
{:ok, user}
else
@ -480,12 +480,14 @@ defmodule WhoNeedHelp.Accounts do
`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
with {:ok, query} <- UserToken.verify_magic_link_token_query(token) do
result =
Repo.transact(fn ->
case query |> lock("FOR UPDATE") |> Repo.one() do
{%User{moderation_status: :suspended}, token} ->
Repo.delete!(token)
{:error, :not_found}
with {:ok, _token} <- Repo.delete(token) do
{:ok, {:rejected, :not_found}}
end
# 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) ->
@ -500,15 +502,25 @@ defmodule WhoNeedHelp.Accounts do
{%User{confirmed_at: nil} = user, _token} ->
user
|> User.confirm_changeset()
|> update_user_and_delete_all_tokens()
|> update_user_and_delete_all_tokens_in_transaction()
{user, token} ->
Repo.delete!(token)
with {:ok, _token} <- Repo.delete(token) do
{:ok, {user, []}}
end
nil ->
{:error, :not_found}
end
end)
case result do
{:ok, {:rejected, reason}} -> {:error, reason}
other -> other
end
else
_invalid_token -> {:error, :not_found}
end
end
@doc ~S"""
@ -516,7 +528,7 @@ defmodule WhoNeedHelp.Accounts do
## Examples
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
iex> deliver_user_update_email_instructions(user, current_email, &"https://example.test/users/settings/confirm-email#token=#{&1}")
{:ok, %{to: ..., body: ...}}
"""
@ -546,6 +558,12 @@ defmodule WhoNeedHelp.Accounts do
:ok
end
def delete_expired_user_tokens(now \\ DateTime.utc_now(:second)) do
now
|> UserToken.expired_tokens_query()
|> Repo.delete_all()
end
## Token helper
defp before_moderation_user(query, nil), do: query
@ -567,7 +585,10 @@ defmodule WhoNeedHelp.Accounts do
end
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
Repo.transact(fn -> update_user_and_delete_all_tokens_in_transaction(changeset) end)
end
defp update_user_and_delete_all_tokens_in_transaction(changeset) do
with {:ok, user} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
@ -575,6 +596,5 @@ defmodule WhoNeedHelp.Accounts do
{:ok, {user, tokens_to_expire}}
end
end)
end
end

View File

@ -21,10 +21,10 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity 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_format(:profile_url, ~r/^https:\/\/[^\s]+$/i,
message: "must be a complete HTTPS URL"
)
|> validate_length(:profile_url, max: 500)
|> validate_length(:profile_url, max: 255)
|> validate_length(:handle, max: 100)
|> unique_constraint([:provider, :provider_uid])
end
@ -51,7 +51,7 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity do
message: "must be a GitHub profile URL"
)
|> validate_length(:provider_uid, max: 255)
|> validate_length(:profile_url, max: 500)
|> validate_length(:profile_url, max: 255)
|> validate_length(:handle, max: 100)
|> unique_constraint([:provider, :provider_uid])
end

View File

@ -89,6 +89,7 @@ defmodule WhoNeedHelp.Accounts.User do
|> validate_length(:display_name, min: 2, max: 80)
|> validate_length(:bio, max: 600)
|> validate_inclusion(:locale, ~w(en uk ru))
|> validate_length(:tip_url, max: 255)
|> validate_url(:tip_url)
end
@ -108,12 +109,12 @@ defmodule WhoNeedHelp.Accounts.User do
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) and host != "" ->
%URI{scheme: "https", host: host}
when is_binary(host) and host != "" ->
[]
_ ->
[{field, "must be a full http(s) URL"}]
[{field, "must be a full HTTPS URL"}]
end
end)
end

View File

@ -153,6 +153,18 @@ defmodule WhoNeedHelp.Accounts.UserToken do
end
end
def expired_tokens_query(now \\ DateTime.utc_now(:second)) do
magic_link_cutoff = DateTime.add(now, -@magic_link_validity_in_minutes, :minute)
change_email_cutoff = DateTime.add(now, -@change_email_validity_in_days, :day)
session_cutoff = DateTime.add(now, -@session_validity_in_days, :day)
from token in UserToken,
where:
(token.context == "login" and token.inserted_at <= ^magic_link_cutoff) or
(like(token.context, "change:%") and token.inserted_at <= ^change_email_cutoff) or
(token.context == "session" and token.inserted_at <= ^session_cutoff)
end
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end

View File

@ -19,13 +19,15 @@ defmodule WhoNeedHelp.Activities do
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)
def subscribe_user(%Scope{user: user}),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activities:user:#{user.id}")
def subscribe_activity(id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activity:#{id}")
def notify_activity_updated(id) do
activity = load_activity(id)
broadcast({:activity_updated, activity})
broadcast_activity(id, {:activity_updated, activity})
broadcast({:activity_updated, load_activity_summary(id)})
broadcast_activity(id, {:activity_updated, load_activity(id)})
:ok
end
@ -37,7 +39,7 @@ defmodule WhoNeedHelp.Activities do
now = DateTime.utc_now(:second)
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
public_creator = Accounts.public_user_query(social_identities: true)
public_creator = Accounts.public_user_query()
Activity
|> where(
@ -231,19 +233,18 @@ defmodule WhoNeedHelp.Activities do
end
with {:ok, activity} <- result do
activity = load_activity(activity.id)
broadcast({:activity_created, activity})
{:ok, activity}
broadcast({:activity_created, load_activity_summary(activity.id)})
{:ok, load_activity(activity.id)}
end
end
def request_to_join(%Scope{user: user} = scope, activity_id) do
result =
with {:ok, _limit} <- Trust.authorize_action(scope, :join_activity) do
with {:ok, activity_id} <- cast_id(activity_id),
{:ok, _limit} <- Trust.authorize_action(scope, :join_activity) do
Repo.transact(fn ->
activity = locked_activity(activity_id)
with %Activity{} = activity <- locked_activity(activity_id) do
now = DateTime.utc_now(:second)
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
cond do
@ -265,6 +266,9 @@ defmodule WhoNeedHelp.Activities do
true ->
upsert_join_request(activity.id, user.id, now)
end
else
nil -> {:error, :not_found}
end
end)
end
@ -275,8 +279,8 @@ defmodule WhoNeedHelp.Activities do
result =
with {:ok, participant_id} <- cast_id(participant_id) do
Repo.transact(fn ->
participant = locked_participant(participant_id)
activity = locked_activity(participant.activity_id)
with %Participant{} = participant <- locked_participant(participant_id),
%Activity{} = activity <- locked_activity(participant.activity_id) do
:ok = Trust.lock_user_pair(activity.creator_id, participant.user_id)
cond do
@ -314,6 +318,9 @@ defmodule WhoNeedHelp.Activities do
{:ok, participant}
end
end
else
nil -> {:error, :not_found}
end
end)
end
@ -328,6 +335,7 @@ defmodule WhoNeedHelp.Activities do
def leave_activity(%Scope{user: user}, activity_id) do
result =
with {:ok, activity_id} <- cast_id(activity_id) do
Repo.transact(fn ->
participant =
Participant
@ -362,6 +370,7 @@ defmodule WhoNeedHelp.Activities do
end
end
end)
end
after_participant_change(result, activity_id, :participant_left)
end
@ -376,9 +385,10 @@ defmodule WhoNeedHelp.Activities do
def send_message(%Scope{user: user} = scope, activity_id, attrs) do
result =
with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message) do
with {:ok, activity_id} <- cast_id(activity_id),
{:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message) do
Repo.transact(fn ->
activity = locked_activity(activity_id)
with %Activity{} = activity <- locked_activity(activity_id) do
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
cond do
@ -400,6 +410,9 @@ defmodule WhoNeedHelp.Activities do
})
|> Repo.insert()
end
else
nil -> {:error, :not_found}
end
end)
end
@ -514,9 +527,8 @@ defmodule WhoNeedHelp.Activities do
defp transition_participant(organizer_id, participant_id, status) do
result =
Repo.transact(fn ->
participant = locked_participant(participant_id)
activity = locked_activity(participant.activity_id)
with %Participant{} = participant <- locked_participant(participant_id),
%Activity{} = activity <- locked_activity(participant.activity_id) do
cond do
activity.creator_id != organizer_id ->
{:error, :forbidden}
@ -543,6 +555,9 @@ defmodule WhoNeedHelp.Activities do
{:ok, participant}
end
end
else
nil -> {:error, :not_found}
end
end)
after_participant_change(result, participant_activity_id(result), :participant_declined)
@ -550,9 +565,9 @@ defmodule WhoNeedHelp.Activities do
defp transition_activity(user_id, activity_id, target_status) do
result =
with {:ok, activity_id} <- cast_id(activity_id) do
Repo.transact(fn ->
activity = locked_activity(activity_id)
with %Activity{} = activity <- locked_activity(activity_id) do
cond do
activity.creator_id != user_id ->
{:error, :forbidden}
@ -569,7 +584,8 @@ defmodule WhoNeedHelp.Activities do
:completed -> %{status: :completed, completed_at: timestamp}
end
with {:ok, activity} <- activity |> Ecto.Changeset.change(attrs) |> Repo.update(),
with {:ok, activity} <-
activity |> Ecto.Changeset.change(attrs) |> Repo.update(),
{:ok, _audit} <-
Trust.audit(
user_id,
@ -580,7 +596,11 @@ defmodule WhoNeedHelp.Activities do
{:ok, activity}
end
end
else
nil -> {:error, :not_found}
end
end)
end
with {:ok, activity} <- result do
activity = load_activity(activity.id)
@ -592,9 +612,10 @@ defmodule WhoNeedHelp.Activities do
defp after_participant_change({:ok, participant}, activity_id, event)
when is_binary(activity_id) do
activity = load_activity(activity_id)
broadcast({:activity_updated, activity})
broadcast_activity(activity.id, {event, participant})
summary = load_activity_summary(activity_id)
broadcast({:activity_updated, summary})
broadcast_user(participant.user_id, {:my_activity_updated, summary, member?(participant)})
broadcast_activity(activity_id, {event, participant})
{:ok, participant}
end
@ -619,6 +640,16 @@ defmodule WhoNeedHelp.Activities do
|> preload_activity()
end
defp load_activity_summary(id) do
public_creator = Accounts.public_user_query()
Activity
|> where([activity], activity.id == ^id)
|> with_approved_participant_count()
|> preload([activity], category: :parent, creator: ^public_creator)
|> Repo.one!()
end
defp get_loaded_activity(id) do
Activity
|> Repo.get(id)
@ -682,20 +713,24 @@ defmodule WhoNeedHelp.Activities do
Activity
|> where([activity], activity.id == ^id)
|> lock("FOR UPDATE")
|> Repo.one!()
|> Repo.one()
end
defp locked_participant(id) do
Participant
|> where([participant], participant.id == ^id)
|> lock("FOR UPDATE")
|> Repo.one!()
|> Repo.one()
end
defp maybe_filter_category(query, value) when value in [nil, ""], do: query
defp maybe_filter_category(query, value),
do: where(query, [activity], activity.category_id == ^value)
defp maybe_filter_category(query, value) do
case Ecto.UUID.cast(value) do
{:ok, category_id} -> where(query, [activity], activity.category_id == ^category_id)
:error -> where(query, [activity], false)
end
end
defp cast_id(value) do
case Ecto.UUID.cast(value) do
@ -706,6 +741,11 @@ defmodule WhoNeedHelp.Activities do
defp broadcast(message), do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, message)
defp broadcast_user(user_id, message),
do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "activities:user:#{user_id}", message)
defp broadcast_activity(id, message),
do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "activity:#{id}", message)
defp member?(%Participant{status: status}), do: status in [:requested, :approved]
end

View File

@ -63,7 +63,11 @@ defmodule WhoNeedHelp.Activities.Activity do
])
|> validate_length(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000)
|> validate_number(:capacity, greater_than_or_equal_to: 2)
|> validate_length(:location_label, max: 255)
|> validate_number(:capacity,
greater_than_or_equal_to: 2,
less_than_or_equal_to: 2_147_483_647
)
|> validate_acceptance(:safety_confirmed,
message: "confirm the safety guidance before publishing"
)
@ -93,8 +97,8 @@ defmodule WhoNeedHelp.Activities.Activity do
latitude = attrs["latitude"] || attrs[:latitude]
longitude = attrs["longitude"] || attrs[:longitude]
with {lat, ""} <- Float.parse(to_string(latitude)),
{lng, ""} <- Float.parse(to_string(longitude)),
with {:ok, lat} <- parse_coordinate(latitude),
{:ok, lng} <- parse_coordinate(longitude),
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
@ -102,6 +106,18 @@ defmodule WhoNeedHelp.Activities.Activity do
end
end
defp parse_coordinate(value) when is_float(value), do: {:ok, value}
defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0}
defp parse_coordinate(value) when is_binary(value) do
case Float.parse(value) do
{coordinate, ""} -> {:ok, coordinate}
_ -> :error
end
end
defp parse_coordinate(_value), do: :error
defp validate_schedule(changeset) do
starts_at = get_field(changeset, :starts_at)
join_deadline = get_field(changeset, :join_deadline)

View File

@ -303,8 +303,8 @@ defmodule WhoNeedHelp.Catalog do
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)
%{"value" => allowed} -> same_scalar_value?(allowed, value)
allowed -> same_scalar_value?(allowed, value)
end)
end
@ -320,6 +320,13 @@ defmodule WhoNeedHelp.Catalog do
defp valid_structured_value?(_field, _value), do: false
defp same_scalar_value?(left, right)
when (is_binary(left) or is_atom(left) or is_number(left) or is_boolean(left)) and
(is_binary(right) or is_atom(right) or is_number(right) or is_boolean(right)),
do: to_string(left) == to_string(right)
defp same_scalar_value?(_left, _right), do: false
defp normalize_structured_value(%{"type" => "boolean"}, "true"), do: true
defp normalize_structured_value(%{"type" => "boolean"}, "false"), do: false
defp normalize_structured_value(_field, value), do: value

View File

@ -36,6 +36,10 @@ defmodule WhoNeedHelp.Catalog.Category do
|> validate_required([:slug, :names])
|> validate_format(:slug, ~r/^[a-z0-9-]+$/)
|> validate_length(:slug, max: 80)
|> validate_number(:sort_order,
greater_than_or_equal_to: -2_147_483_648,
less_than_or_equal_to: 2_147_483_647
)
|> validate_names()
|> validate_descriptions()
|> validate_structured_fields()

View File

@ -200,18 +200,20 @@ defmodule WhoNeedHelp.Help do
end
def accept_request(%Scope{user: helper}, request_id) do
result =
with {:ok, request_id} <- cast_id(request_id),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) 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!()
|> Repo.one()
if request do
:ok = Trust.lock_user_pair(request.requester_id, helper.id)
cond do
@ -255,7 +257,12 @@ defmodule WhoNeedHelp.Help do
{:ok, assignment}
end
end
else
{:error, :not_found}
end
end)
else
:error -> {:error, :not_found}
end
case result do
@ -283,9 +290,11 @@ defmodule WhoNeedHelp.Help do
end
def verify_handover(%Scope{user: user}, assignment_id, code) do
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :verify_handover) do
with {:ok, assignment_id} <- cast_id(assignment_id),
true <- valid_handover_code?(code),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :verify_handover) do
Repo.transact(fn ->
assignment = locked_assignment(assignment_id)
with %Assignment{} = assignment <- locked_assignment(assignment_id) do
request = Repo.get!(HelpRequest, assignment.request_id)
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
@ -311,21 +320,32 @@ defmodule WhoNeedHelp.Help do
|> maybe_complete(request)
|> audit_assignment_transition(user.id, "handover.verified", request.id)
end
else
nil -> {:error, :not_found}
end
end)
else
:error -> {:error, :not_found}
false -> {:error, :invalid_code}
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
with {:ok, request_id} <- cast_id(request_id),
{: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!()
|> Repo.one()
if request.requester_id == user.id and request.status in [:open, :matched] do
cond do
is_nil(request) ->
{:error, :not_found}
request.requester_id == user.id and request.status in [:open, :matched] ->
now = DateTime.utc_now(:second)
assignment =
@ -343,10 +363,13 @@ defmodule WhoNeedHelp.Help do
Trust.audit(user.id, "request.cancelled", "request", request.id) do
{:ok, request}
end
else
true ->
{:error, :forbidden}
end
end)
else
:error -> {:error, :not_found}
end
|> case do
{:ok, request} ->
@ -361,12 +384,14 @@ defmodule WhoNeedHelp.Help do
end
def withdraw_assignment(%Scope{user: user}, assignment_id) do
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do
with {:ok, assignment_id} <- cast_id(assignment_id),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do
Repo.transact(fn ->
assignment = locked_assignment(assignment_id)
with %Assignment{} = assignment <- locked_assignment(assignment_id) do
request = Repo.get!(HelpRequest, assignment.request_id)
if assignment.helper_id == user.id and assignment.status in [:accepted, :in_progress] do
if assignment.helper_id == user.id and
assignment.status in [:accepted, :in_progress] do
now = DateTime.utc_now(:second)
with {:ok, assignment} <-
@ -386,7 +411,12 @@ defmodule WhoNeedHelp.Help do
else
{:error, :invalid_transition}
end
else
nil -> {:error, :not_found}
end
end)
else
:error -> {:error, :not_found}
end
|> after_transition()
|> case do
@ -469,8 +499,8 @@ defmodule WhoNeedHelp.Help do
request.assignment.status in [:accepted, :in_progress] and
not Trust.blocked_between?(scope.user.id, request.requester_id)
if (owner or matched_participant) and
request.location_visibility in [:hidden, :exact_for_active_match] do
if owner or
(matched_participant and request.location_visibility == :exact_for_active_match) do
%Geo.Point{coordinates: {lng, lat}} = request.location
%{latitude: lat, longitude: lng, exact: true}
else
@ -490,9 +520,10 @@ defmodule WhoNeedHelp.Help do
end
defp transition_assignment(user, assignment_id, action) do
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), action) do
with {:ok, assignment_id} <- cast_id(assignment_id),
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), action) do
Repo.transact(fn ->
assignment = locked_assignment(assignment_id)
with %Assignment{} = assignment <- locked_assignment(assignment_id) do
request = Repo.get!(HelpRequest, assignment.request_id)
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
now = DateTime.utc_now(:second)
@ -509,9 +540,13 @@ defmodule WhoNeedHelp.Help do
{: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
Trust.audit(
user.id,
"assignment.started",
"assignment",
assignment.id,
%{"request_id" => request.id}
) do
{:ok, assignment}
end
@ -536,7 +571,12 @@ defmodule WhoNeedHelp.Help do
{:error, :invalid_transition}
end
end
else
nil -> {:error, :not_found}
end
end)
else
:error -> {:error, :not_found}
end
|> after_transition()
end
@ -571,10 +611,15 @@ defmodule WhoNeedHelp.Help do
Assignment
|> where([a], a.id == ^id)
|> lock("FOR UPDATE")
|> Repo.one!()
|> Repo.one()
end
defp code_hash(code), do: :crypto.hash(:sha256, to_string(code))
defp valid_handover_code?(code) when is_binary(code),
do: Regex.match?(~r/^\d{6}$/, code)
defp valid_handover_code?(_code), do: false
defp code_hash(code) when is_binary(code), do: :crypto.hash(:sha256, code)
defp after_transition({:ok, assignment}) do
request = get_request!(assignment.request_id)
@ -648,8 +693,31 @@ defmodule WhoNeedHelp.Help do
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 maybe_filter(query, :category_id, value) do
case Ecto.UUID.cast(value) do
{:ok, category_id} -> where(query, [request], request.category_id == ^category_id)
:error -> where(query, [request], false)
end
end
defp maybe_filter(query, :urgency, value) do
case normalize_urgency(value) do
{:ok, urgency} -> where(query, [request], request.urgency == ^urgency)
:error -> where(query, [request], false)
end
end
defp normalize_urgency(value) when value in [:now, "now"], do: {:ok, :now}
defp normalize_urgency(value) when value in [:today, "today"], do: {:ok, :today}
defp normalize_urgency(value) when value in [:scheduled, "scheduled"], do: {:ok, :scheduled}
defp normalize_urgency(_value), do: :error
defp cast_id(id) do
case Ecto.UUID.cast(id) do
{:ok, id} -> {:ok, id}
:error -> :error
end
end
defp validate_structured_data(changeset) do
category_id = Ecto.Changeset.get_field(changeset, :category_id)

View File

@ -64,6 +64,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|> validate_length(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000)
|> validate_length(:pickup_instructions, max: 1_000)
|> validate_length(:location_label, max: 255)
|> validate_acceptance(:safety_confirmed,
message: "confirm the safety guidance before publishing"
)
@ -80,8 +81,8 @@ defmodule WhoNeedHelp.Help.HelpRequest do
lat = attrs["latitude"] || attrs[:latitude]
lng = attrs["longitude"] || attrs[:longitude]
with {lat, ""} <- Float.parse(to_string(lat)),
{lng, ""} <- Float.parse(to_string(lng)),
with {:ok, lat} <- parse_coordinate(lat),
{:ok, lng} <- parse_coordinate(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
@ -89,6 +90,18 @@ defmodule WhoNeedHelp.Help.HelpRequest do
end
end
defp parse_coordinate(value) when is_float(value), do: {:ok, value}
defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0}
defp parse_coordinate(value) when is_binary(value) do
case Float.parse(value) do
{coordinate, ""} -> {:ok, coordinate}
_ -> :error
end
end
defp parse_coordinate(_value), do: :error
defp validate_expiry(changeset) do
validate_change(changeset, :expires_at, fn :expires_at, value ->
if DateTime.after?(value, DateTime.utc_now()),

View File

@ -53,9 +53,10 @@ defmodule WhoNeedHelp.Messaging do
Assignment
|> where([current], current.id == ^assignment.id)
|> lock("FOR UPDATE")
|> Repo.one!()
|> Repo.preload(:request)
|> Repo.one()
if current do
current = Repo.preload(current, :request)
request = current.request
recipient_id = counterpart_id(user.id, current, request)
@ -79,6 +80,9 @@ defmodule WhoNeedHelp.Messaging do
false -> {:error, :forbidden}
other -> other
end
else
{:error, :not_found}
end
end)
with {:ok, message} <- result do

View File

@ -26,12 +26,24 @@ defmodule WhoNeedHelp.Tracking.Position do
lat = attrs["latitude"] || attrs[:latitude]
lng = attrs["longitude"] || attrs[:longitude]
with {lat, ""} <- Float.parse(to_string(lat)),
{lng, ""} <- Float.parse(to_string(lng)),
with {:ok, lat} <- parse_coordinate(lat),
{:ok, lng} <- parse_coordinate(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
defp parse_coordinate(value) when is_float(value), do: {:ok, value}
defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0}
defp parse_coordinate(value) when is_binary(value) do
case Float.parse(value) do
{coordinate, ""} -> {:ok, coordinate}
_ -> :error
end
end
defp parse_coordinate(_value), do: :error
end

View File

@ -53,8 +53,9 @@ defmodule WhoNeedHelp.Trust do
Assignment
|> where([current], current.id == ^assignment.id)
|> lock("FOR UPDATE")
|> Repo.one!()
|> Repo.one()
if current do
request = Repo.get!(HelpRequest, current.request_id)
cond do
@ -95,6 +96,9 @@ defmodule WhoNeedHelp.Trust do
other -> other
end
end
else
{:error, :not_found}
end
end)
end
end
@ -870,7 +874,7 @@ defmodule WhoNeedHelp.Trust do
defp users_by_id([]), do: %{}
defp users_by_id(ids) do
User
Accounts.public_user_query()
|> where([user], user.id in ^ids)
|> Repo.all()
|> Map.new(&{&1.id, &1})
@ -1059,15 +1063,18 @@ defmodule WhoNeedHelp.Trust do
defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id})
when is_binary(request_id) do
with {:ok, request_id} <- cast_id(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
end
defp authorize_report_target(scope, %{"assignment_id" => assignment_id})
when is_binary(assignment_id) do
with {:ok, assignment_id} <- cast_id(assignment_id) do
case Repo.get(Assignment, assignment_id) do
%Assignment{} = assignment ->
if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden}
@ -1076,8 +1083,10 @@ defmodule WhoNeedHelp.Trust do
{:error, :not_found}
end
end
end
defp authorize_report_target(scope, %{"message_id" => message_id}) when is_binary(message_id) do
with {:ok, message_id} <- cast_id(message_id) do
case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do
%Message{sender_id: sender_id, assignment: assignment} ->
cond do
@ -1090,9 +1099,11 @@ defmodule WhoNeedHelp.Trust do
{:error, :not_found}
end
end
end
defp authorize_report_target(%Scope{user: user}, %{"activity_id" => activity_id})
when is_binary(activity_id) do
with {:ok, activity_id} <- cast_id(activity_id) do
case Repo.get(Activity, activity_id) do
%Activity{creator_id: creator_id} when creator_id == user.id ->
{:error, :cannot_report_self}
@ -1106,12 +1117,14 @@ defmodule WhoNeedHelp.Trust do
{:error, :not_found}
end
end
end
defp authorize_report_target(
%Scope{user: user},
%{"activity_message_id" => message_id}
)
when is_binary(message_id) do
with {:ok, message_id} <- cast_id(message_id) do
case Repo.get(ActivityMessage, message_id) do
%ActivityMessage{sender_id: sender_id} when sender_id == user.id ->
{:error, :cannot_report_self}
@ -1125,6 +1138,7 @@ defmodule WhoNeedHelp.Trust do
{:error, :not_found}
end
end
end
defp authorize_report_target(_scope, _attrs), do: {:error, :invalid_target}
@ -1195,18 +1209,16 @@ defmodule WhoNeedHelp.Trust do
)
|> 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)
assignment.status in [:accepted, :in_progress] and
((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))
)
|> select([assignment, request], {assignment.id, request.id, assignment.status})
|> select([assignment, request], {assignment.id, request.id})
|> Repo.all()
active_assignment_ids =
assignments
|> Enum.filter(fn {_assignment_id, _request_id, status} ->
status in [:accepted, :in_progress]
end)
|> Enum.map(&elem(&1, 0))
active_assignment_ids = Enum.map(assignments, &elem(&1, 0))
active_sessions =
if active_assignment_ids == [] do

View File

@ -30,7 +30,11 @@ defmodule WhoNeedHelp.Trust.RateLimiter do
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))
raw =
if is_map(policies) do
Map.get(policies, action) || Map.get(policies, String.to_existing_atom(action))
end
case raw do
%{limit: limit, window_seconds: window}

View File

@ -16,13 +16,15 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
with {:ok, expired} <- expire_available(now, 0),
{:ok, cleanup} <- Tracking.cleanup_finished_sessions() do
{pruned_buckets, _} = RateLimiter.prune_expired()
{pruned_user_tokens, _} = WhoNeedHelp.Accounts.delete_expired_user_tokens(now)
{:ok,
%{
expired: expired,
tracking_positions_deleted: cleanup.positions_deleted,
tracking_sessions_ended: cleanup.sessions_ended,
rate_limit_buckets_pruned: pruned_buckets
rate_limit_buckets_pruned: pruned_buckets,
user_tokens_pruned: pruned_user_tokens
}}
end
end

View File

@ -195,7 +195,7 @@ defmodule WhoNeedHelpWeb.CoreComponents do
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> assign_new(:value, fn -> safe_form_value(field.value) end)
|> input()
end
@ -289,7 +289,7 @@ defmodule WhoNeedHelpWeb.CoreComponents do
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
value={normalized_input_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
@ -302,6 +302,24 @@ defmodule WhoNeedHelpWeb.CoreComponents do
"""
end
defp normalized_input_value("password", _value), do: nil
defp normalized_input_value(type, value),
do: Phoenix.HTML.Form.normalize_value(type, value)
defp safe_form_value(value) when is_list(value) do
if Enum.all?(value, &safe_form_value?/1),
do: value,
else: nil
end
defp safe_form_value(value) do
if Phoenix.HTML.Safe.impl_for(value), do: value, else: nil
end
defp safe_form_value?(value) when is_list(value), do: Enum.all?(value, &safe_form_value?/1)
defp safe_form_value?(value), do: not is_nil(Phoenix.HTML.Safe.impl_for(value))
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""

View File

@ -16,6 +16,7 @@ defmodule WhoNeedHelpWeb.MetricsController do
conn
|> put_resp_header("cache-control", "no-store")
|> put_resp_header("x-wnh-node", to_string(node()))
|> put_resp_content_type(@content_type)
|> send_resp(:ok, body)
else

View File

@ -1,41 +1,45 @@
defmodule WhoNeedHelpWeb.UserRegistrationController do
use WhoNeedHelpWeb, :controller
require Logger
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.User
alias WhoNeedHelp.Trust.RateLimiter
plug :put_no_store
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()
def create(conn, %{"user" => user_params}) when is_map(user_params) do
user_params = normalize_registration_params(user_params)
email_scope = normalize_email_scope(user_params["email"])
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,
gettext(
"An email was sent to %{email}, please access it to confirm your account.",
email: user.email
)
)
|> redirect(to: ~p"/users/log-in")
deliver_registration_instructions(conn, user)
registration_response(conn)
{:error, %Ecto.Changeset{} = changeset} ->
if unique_email_error?(changeset) do
case Accounts.get_user_by_email(user_params["email"]) do
%User{moderation_status: status} = user when status != :suspended ->
deliver_registration_instructions(conn, user)
_missing_or_suspended ->
:ok
end
registration_response(conn)
else
render(conn, :new, changeset: changeset)
end
end
else
{:error, :rate_limited} ->
conn
@ -49,4 +53,76 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
)
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> put_flash(:error, gettext("The registration form is invalid."))
|> render(:new,
changeset: Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
)
end
defp normalize_email_scope(email) when is_binary(email),
do: email |> String.trim() |> String.downcase()
defp normalize_email_scope(_email), do: ""
defp normalize_registration_params(params) do
params
|> normalize_binary_param("email")
|> normalize_binary_param("display_name")
|> normalize_binary_param("locale")
|> normalize_boolean_param("terms_accepted")
end
defp normalize_binary_param(params, key) do
case Map.fetch(params, key) do
{:ok, value} when is_binary(value) -> params
{:ok, _invalid} -> Map.put(params, key, "")
:error -> params
end
end
defp normalize_boolean_param(params, key) do
case Map.fetch(params, key) do
{:ok, value} when is_binary(value) or is_boolean(value) -> params
{:ok, _invalid} -> Map.put(params, key, false)
:error -> params
end
end
defp deliver_registration_instructions(conn, user) do
case Accounts.deliver_login_instructions(
user,
&"#{url(conn, ~p"/users/log-in")}#token=#{URI.encode_www_form(&1)}"
) do
{:ok, _email} -> :ok
{:error, _reason} -> Logger.warning("Unable to send registration login instructions")
end
end
defp registration_response(conn) do
conn
|> put_flash(
:info,
gettext(
"If this address can be registered or signed in, login instructions will arrive shortly."
)
)
|> redirect(to: ~p"/users/log-in")
end
defp unique_email_error?(%Ecto.Changeset{} = changeset) do
Enum.any?(changeset.errors, fn
{:email, {_message, options}} ->
Keyword.get(options, :constraint) == :unique or
Keyword.get(options, :validation) == :unsafe_unique
_other ->
false
end)
end
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")
end

View File

@ -1,10 +1,15 @@
defmodule WhoNeedHelpWeb.UserSessionController do
use WhoNeedHelpWeb, :controller
require Logger
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.UserAuth
plug :assign_magic_link_form
plug :put_no_store
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")
@ -13,7 +18,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do
end
# magic link login
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
def create(conn, %{"user" => %{"token" => token} = user_params} = params)
when is_binary(token) do
info =
case params do
%{"_action" => "confirmed"} -> gettext("User confirmed successfully.")
@ -34,7 +40,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do
end
# email + password login
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params})
when is_binary(email) and is_binary(password) do
email_scope = email |> String.trim() |> String.downcase()
case RateLimiter.check(:password_login_email, email_scope) do
@ -56,17 +63,23 @@ defmodule WhoNeedHelpWeb.UserSessionController do
end
# magic link request
def create(conn, %{"user" => %{"email" => email}}) do
def create(conn, %{"user" => %{"email" => email}}) when is_binary(email) do
email_scope = email |> String.trim() |> String.downcase()
case RateLimiter.check(:magic_link_email, email_scope) do
{:ok, _limit} ->
case Accounts.get_user_by_email(email) do
%Accounts.User{moderation_status: status} = user when status != :suspended ->
Accounts.deliver_login_instructions(
case Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
&"#{url(~p"/users/log-in")}#token=#{URI.encode_www_form(&1)}"
) do
{:ok, _email} ->
:ok
{:error, _reason} ->
Logger.warning("Unable to send requested login instructions")
end
_missing_or_suspended ->
:ok
@ -92,19 +105,11 @@ defmodule WhoNeedHelpWeb.UserSessionController do
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")
def create(conn, _params) do
conn
|> assign(:user, user)
|> assign(:form, form)
|> render(:confirm)
else
conn
|> put_flash(:error, gettext("Magic link is invalid or it has expired."))
|> redirect(to: ~p"/users/log-in")
end
|> put_status(:bad_request)
|> put_flash(:error, gettext("The sign-in form is invalid."))
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
end
def delete(conn, _params) do
@ -119,4 +124,14 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|> put_flash(:error, gettext("Invalid email or password"))
|> render(:new, form: Phoenix.Component.to_form(user_params, as: "user"))
end
defp assign_magic_link_form(conn, _opts) do
assign(
conn,
:magic_link_form,
Phoenix.Component.to_form(%{"token" => ""}, as: "user")
)
end
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")
end

View File

@ -1,70 +0,0 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>{gettext("Welcome %{email}", email: @user.email)}</.header>
</div>
<.form
:if={!@user.confirmed_at}
for={@form}
id="confirmation_form"
action={~p"/users/log-in?_action=confirmed"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with={gettext("Confirming...")}
class="btn btn-primary w-full"
>
{gettext("Confirm and stay logged in")}
</.button>
<.button
phx-disable-with={gettext("Confirming...")}
class="btn btn-primary btn-soft w-full mt-2"
>
{gettext("Confirm and log in only this time")}
</.button>
</.form>
<.form
:if={@user.confirmed_at}
for={@form}
id="login_form"
action={~p"/users/log-in"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<%= if @current_scope do %>
<.button
variant="primary"
phx-disable-with={gettext("Logging in...")}
class="btn btn-primary w-full"
>
{gettext("Log in")}
</.button>
<% else %>
<.button
id="remember-login-button"
name={@form[:remember_me].name}
value="true"
phx-disable-with={gettext("Logging in...")}
class="btn btn-primary w-full"
>
{gettext("Keep me logged in on this device")}
</.button>
<.button
phx-disable-with={gettext("Logging in...")}
class="btn btn-primary btn-soft w-full mt-2"
>
{gettext("Log me in only this time")}
</.button>
<% end %>
</.form>
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
{gettext("Tip: If you prefer passwords, you can enable them in the user settings.")}
</p>
</div>
</Layouts.app>

View File

@ -30,6 +30,39 @@
</div>
</div>
<.form
for={@magic_link_form}
id="magic-link-fragment-form"
action={~p"/users/log-in?_action=confirmed"}
hidden
>
<input
id="magic-link-fragment-token"
type="hidden"
name={@magic_link_form[:token].name}
value=""
/>
<p class="alert alert-info mb-4">
{gettext("Your secure email link is ready. Continue to sign in.")}
</p>
<.button
id="remember-login-button"
name={@magic_link_form[:remember_me].name}
value="true"
phx-disable-with={gettext("Confirming...")}
class="btn btn-primary w-full"
>
{gettext("Confirm and stay logged in")}
</.button>
<.button
phx-disable-with={gettext("Confirming...")}
class="btn btn-primary btn-soft w-full mt-2"
>
{gettext("Confirm and log in only this time")}
</.button>
</.form>
<div id="standard-login-options">
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
@ -73,4 +106,5 @@
</.button>
</.form>
</div>
</div>
</Layouts.app>

View File

@ -1,44 +1,54 @@
defmodule WhoNeedHelpWeb.UserSettingsController do
use WhoNeedHelpWeb, :controller
require Logger
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.UserAuth
import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2]
plug :require_sudo_mode
plug :assign_email_and_password_changesets
plug :require_sudo_mode when action in [:edit, :update]
plug :assign_email_and_password_changesets when action in [:edit, :update]
def edit(conn, _params) do
render(conn, :edit)
end
def update(conn, %{"action" => "update_email"} = params) do
%{"user" => user_params} = params
def update(conn, %{"action" => "update_email", "user" => user_params})
when is_map(user_params) do
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}")
)
new_email =
changeset
|> Ecto.Changeset.get_change(:email, "")
|> String.trim()
|> String.downcase()
case RateLimiter.check(:email_change_email, new_email) do
{:ok, _limit} ->
deliver_email_change_instructions(conn, user, changeset)
{:error, :rate_limited} ->
conn
|> put_status(:too_many_requests)
|> put_flash(
:info,
gettext("A link to confirm your email change has been sent to the new address.")
:error,
gettext("Too many email-change requests in the configured time window.")
)
|> redirect(to: ~p"/users/settings")
|> render(:edit)
end
changeset ->
render(conn, :edit, email_changeset: %{changeset | action: :insert})
end
end
def update(conn, %{"action" => "update_password"} = params) do
%{"user" => user_params} = params
def update(conn, %{"action" => "update_password", "user" => user_params})
when is_map(user_params) do
user = conn.assigns.current_scope.user
case Accounts.update_user_password(user, user_params) do
@ -56,7 +66,18 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
end
end
def confirm_email(conn, %{"token" => token}) do
def update(conn, _params) do
conn
|> put_status(:bad_request)
|> put_flash(:error, gettext("The settings form is invalid."))
|> render(:edit)
end
def confirm_email_page(conn, _params) do
render(conn, :confirm_email)
end
def confirm_email(conn, %{"token" => token}) when is_binary(token) do
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
{:ok, _user} ->
conn
@ -70,6 +91,12 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
end
end
def confirm_email(conn, _params) do
conn
|> put_flash(:error, gettext("Email change link is invalid or it has expired."))
|> redirect(to: ~p"/users/settings")
end
defp assign_email_and_password_changesets(conn, _opts) do
user = conn.assigns.current_scope.user
@ -77,4 +104,30 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
end
defp deliver_email_change_instructions(conn, user, changeset) do
case Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&"#{url(~p"/users/settings/confirm-email")}#token=#{URI.encode_www_form(&1)}"
) do
{:ok, _email} ->
conn
|> put_flash(
:info,
gettext("A link to confirm your email change has been sent to the new address.")
)
|> redirect(to: ~p"/users/settings")
{:error, _reason} ->
Logger.warning("Unable to send email-change instructions")
conn
|> put_flash(
:error,
gettext("The confirmation email could not be sent. Please try again.")
)
|> redirect(to: ~p"/users/settings")
end
end
end

View File

@ -0,0 +1,26 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-md text-center">
<.header>
{gettext("Confirm email change")}
<:subtitle>{gettext("Confirm the new email address for your account.")}</:subtitle>
</.header>
<.form
for={%{}}
action={~p"/users/settings/confirm-email"}
id="email-change-fragment-form"
hidden
>
<input id="email-change-fragment-token" type="hidden" name="token" value="" />
<.button variant="primary" phx-disable-with={gettext("Confirming...")}>
{gettext("Confirm email change")}
</.button>
</.form>
<noscript>
<p class="alert alert-error">
{gettext("JavaScript is required to confirm this protected email link.")}
</p>
</noscript>
</div>
</Layouts.app>

View File

@ -8,7 +8,8 @@ defmodule WhoNeedHelpWeb.Endpoint do
store: :cookie,
key: "_who_need_help_key",
signing_salt: "XN+IwK3w",
same_site: "Lax"
same_site: "Lax",
secure: Application.compile_env(:who_need_help, :secure_cookies, false)
]
socket "/live", Phoenix.LiveView.Socket,
@ -45,8 +46,7 @@ defmodule WhoNeedHelpWeb.Endpoint do
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
parsers: [:urlencoded, :json],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride

View File

@ -1,12 +1,15 @@
defmodule WhoNeedHelpWeb.ActivityLive.Index do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Activities, Catalog}
alias WhoNeedHelp.{Activities, Catalog, Pagination}
alias WhoNeedHelp.Activities.Activity
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: Activities.subscribe()
if connected?(socket) do
Activities.subscribe()
Activities.subscribe_user(socket.assigns.current_scope)
end
{:ok,
socket
@ -18,8 +21,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
def handle_info({event, activity}, socket)
when event in [:activity_created, :activity_updated] do
user_id = socket.assigns.current_scope.user.id
already_mine? = Enum.any?(socket.assigns.my_activities, &(&1.id == activity.id))
activities =
{activities, activities_cursor} =
update_entry(
socket.assigns.activities,
activity,
@ -28,24 +32,44 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
activity,
socket.assigns.filters
),
:asc
:asc,
socket.assigns.activities_cursor
)
my_activities =
{my_activities, my_activities_cursor} =
update_entry(
socket.assigns.my_activities,
activity,
Activities.member_activity?(activity, user_id),
:desc
already_mine? or activity.creator_id == user_id,
:desc,
socket.assigns.my_activities_cursor
)
{:noreply,
socket
|> assign(:activities, activities)
|> assign(:activities_cursor, activities_cursor)
|> assign(:my_activities, my_activities)
|> assign(:my_activities_cursor, my_activities_cursor)
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))}
end
def handle_info({:my_activity_updated, activity, member?}, socket) do
{my_activities, my_activities_cursor} =
update_entry(
socket.assigns.my_activities,
activity,
member?,
:desc,
socket.assigns.my_activities_cursor
)
{:noreply,
socket
|> assign(:my_activities, my_activities)
|> assign(:my_activities_cursor, my_activities_cursor)}
end
@impl true
def handle_event("filter", %{"filters" => filters}, socket) do
{:noreply, socket |> assign(:filters, filters) |> load()}
@ -106,10 +130,23 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
end
defp update_entry(entries, activity, visible?, direction) do
defp update_entry(entries, activity, visible?, direction, cursor) do
window_limit = max(length(entries), Pagination.limit([]))
entries = Enum.reject(entries, &(&1.id == activity.id))
entries = if visible?, do: [activity | entries], else: entries
Enum.sort_by(entries, &{&1.starts_at, &1.id}, direction)
entries = Enum.sort_by(entries, &{&1.starts_at, &1.id}, direction)
overflow? = length(entries) > window_limit
entries = Enum.take(entries, window_limit)
cursor =
if overflow? do
activity = List.last(entries)
Pagination.encode(activity.starts_at, activity.id)
else
cursor
end
{entries, cursor}
end
defp marker(activity) do
@ -195,6 +232,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
<.link
:for={activity <- @activities}
id={"open-activity-#{activity.id}"}
navigate={~p"/activities/#{activity.id}"}
class="block rounded-3xl border border-base-300 bg-base-100 p-6 transition hover:border-info/50"
>
@ -255,6 +293,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
<div class="mt-4 grid gap-3 md:grid-cols-2">
<.link
:for={activity <- @my_activities}
id={"my-activity-#{activity.id}"}
navigate={~p"/activities/#{activity.id}"}
class="rounded-2xl bg-base-200 p-4"
>

View File

@ -90,7 +90,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
defp option_value(option), do: option
defp structured_value(form, key) do
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
case Map.get(form.params, "structured_data") do
structured_data when is_map(structured_data) -> Map.get(structured_data, key)
_invalid_or_missing -> nil
end
end
defp error_message(:account_not_eligible),

View File

@ -1,7 +1,9 @@
defmodule WhoNeedHelpWeb.ActivityLive.Show do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Activities, Trust}
alias WhoNeedHelp.{Activities, Pagination, Trust}
@initial_message_window 50
@impl true
def mount(%{"id" => id}, _session, socket) do
@ -20,9 +22,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
@impl true
def handle_info({:activity_message, message}, socket) do
activity = socket.assigns.activity
messages = append_message(activity.messages, message)
{:noreply, assign(socket, :activity, %{activity | messages: messages})}
{:noreply, put_realtime_message(socket, message)}
end
def handle_info({event, _payload}, socket)
@ -80,13 +80,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
params
) do
{:ok, message} ->
activity = socket.assigns.activity
{:noreply,
assign(socket, :activity, %{
activity
| messages: append_message(activity.messages, message)
})}
{:noreply, put_realtime_message(socket, message)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))}
@ -108,7 +102,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
{:noreply,
socket
|> assign(:activity, %{activity | messages: older ++ activity.messages})
|> assign(:messages_cursor, page.next_cursor)}
|> assign(:messages_cursor, page.next_cursor)
|> update(:message_window_limit, &(&1 + length(older)))}
end
def handle_event("report", %{"report" => params}, socket) do
@ -212,6 +207,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|> assign(:page_title, activity.title)
|> assign(:activity, activity)
|> assign(:messages_cursor, messages.next_cursor)
|> assign(:message_window_limit, max(length(messages.entries), @initial_message_window))
|> assign(:viewer_participant, viewer_participant)
|> assign(:organizer?, organizer?)
|> assign(:approved?, approved?)
@ -246,6 +242,23 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
if Enum.any?(messages, &(&1.id == message.id)), do: messages, else: messages ++ [message]
end
defp put_realtime_message(socket, message) do
activity = socket.assigns.activity
messages = append_message(activity.messages, message)
window_limit = socket.assigns.message_window_limit
if length(messages) > window_limit do
messages = Enum.take(messages, -window_limit)
oldest = hd(messages)
socket
|> assign(:activity, %{activity | messages: messages})
|> assign(:messages_cursor, Pagination.encode(oldest.inserted_at, oldest.id))
else
assign(socket, :activity, %{activity | messages: messages})
end
end
defp structured_details(activity, locale) do
activity.category
|> WhoNeedHelp.Catalog.structured_fields()
@ -389,7 +402,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
:for={identity <- @activity.creator.social_identities}
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow"
rel="noopener noreferrer nofollow ugc"
class={[
"badge badge-outline",
identity.verified_at && "badge-success",
@ -540,7 +553,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
:for={identity <- participant.user.social_identities}
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow"
rel="noopener noreferrer nofollow ugc"
class={[
"badge badge-xs badge-outline",
identity.verified_at && "badge-success",
@ -599,7 +612,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
:for={identity <- participant.user.social_identities}
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow"
rel="noopener noreferrer nofollow ugc"
class={[
"badge badge-xs badge-outline",
identity.verified_at && "badge-success",

View File

@ -100,7 +100,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
}
|> Map.reject(fn {_locale, description} -> description in [nil, ""] end)
with {:ok, structured_fields} <- Jason.decode(params["structured_fields_json"] || "{}"),
with {:ok, structured_fields} <- decode_structured_fields(params["structured_fields_json"]),
true <- is_map(structured_fields) do
attrs = %{
"slug" => params["slug"],
@ -190,6 +190,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
{:noreply, put_flash(socket, :error, error_message(reason))}
end
defp decode_structured_fields(value) when value in [nil, ""], do: {:ok, %{}}
defp decode_structured_fields(value) when is_binary(value), do: Jason.decode(value)
defp decode_structured_fields(_value), do: {:error, :invalid_type}
defp load(socket) do
reports = Trust.paginate_reports(socket.assigns.current_scope)
signals = Trust.paginate_abuse_signals(socket.assigns.current_scope)

View File

@ -44,15 +44,22 @@ defmodule WhoNeedHelpWeb.ProfileLive do
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} ->
with {:ok, _limit} <- Trust.authorize_action(socket.assigns.current_scope, :add_social),
{:ok, _identity} <- Accounts.add_social_identity(user, params) do
{:noreply,
socket
|> assign_social_identities(user)
|> put_flash(:info, gettext("Social link added as unverified."))}
{:error, changeset} ->
else
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :social_form, to_form(changeset, as: :social_identity))}
{:error, :rate_limited} ->
{:noreply,
put_flash(socket, :error, gettext("Too many actions in the configured time window."))}
{:error, :account_not_eligible} ->
{:noreply, put_flash(socket, :error, gettext("This account cannot perform that action."))}
end
end
@ -273,7 +280,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
<a
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow"
rel="noopener noreferrer nofollow ugc"
class="link mt-1 block truncate text-sm"
>
{identity.handle || identity.profile_url}

View File

@ -1,7 +1,7 @@
defmodule WhoNeedHelpWeb.RequestLive.Index do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Help, Trust}
alias WhoNeedHelp.{Help, Pagination, Trust}
@impl true
def mount(_params, _session, socket) do
@ -17,7 +17,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
user = socket.assigns.current_scope.user
requests =
{requests, requests_cursor} =
update_entry(
socket.assigns.requests,
request,
@ -26,21 +26,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
request,
socket.assigns.filters
),
:open
:open,
socket.assigns.requests_cursor
)
my_requests =
{my_requests, my_requests_cursor} =
update_entry(
socket.assigns.my_requests,
request,
request.requester_id == user.id,
:mine
:mine,
socket.assigns.my_requests_cursor
)
socket =
socket
|> assign(:requests, requests)
|> assign(:requests_cursor, requests_cursor)
|> assign(:my_requests, my_requests)
|> assign(:my_requests_cursor, my_requests_cursor)
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))
|> maybe_refresh_reputation(request, user.id)
@ -106,7 +110,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
end
defp update_entry(entries, request, visible?, order) do
defp update_entry(entries, request, visible?, order, cursor) do
window_limit = max(length(entries), Pagination.limit([]))
entries = Enum.reject(entries, &(&1.id == request.id))
entries =
@ -114,10 +119,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
do: [request | entries],
else: entries
entries =
case order do
:open -> Enum.sort_by(entries, &{&1.expires_at, &1.id}, :asc)
:mine -> Enum.sort_by(entries, &{&1.inserted_at, &1.id}, :desc)
end
overflow? = length(entries) > window_limit
entries = Enum.take(entries, window_limit)
cursor =
if overflow? do
request = List.last(entries)
timestamp = if order == :open, do: request.expires_at, else: request.inserted_at
Pagination.encode(timestamp, request.id)
else
cursor
end
{entries, cursor}
end
defp maybe_refresh_reputation(socket, request, user_id) do
@ -240,6 +260,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
<.link
:for={request <- @requests}
id={"open-request-#{request.id}"}
navigate={~p"/requests/#{request.id}"}
class="help-card block rounded-3xl border border-base-300 bg-base-100 p-6"
>
@ -322,6 +343,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
<div class="mt-4 grid gap-3 md:grid-cols-2">
<.link
:for={request <- @my_requests}
id={"my-request-#{request.id}"}
navigate={~p"/requests/#{request.id}"}
class="rounded-2xl bg-base-200 p-4"
>

View File

@ -98,7 +98,10 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
defp option_value(option), do: option
defp structured_value(form, key) do
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
case Map.get(form.params, "structured_data") do
structured_data when is_map(structured_data) -> Map.get(structured_data, key)
_invalid_or_missing -> nil
end
end
defp category_description(nil, _locale), do: nil

View File

@ -1,10 +1,11 @@
defmodule WhoNeedHelpWeb.RequestLive.Show do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Help, Messaging, Tracking, Trust}
alias WhoNeedHelp.{Help, Messaging, Pagination, Tracking, Trust}
alias WhoNeedHelpWeb.Presence
@e2e_routes Application.compile_env(:who_need_help, :e2e_routes, false)
@initial_message_window 50
@impl true
def mount(%{"id" => id}, _session, socket) do
@ -76,7 +77,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
end
def handle_info({:new_message, message}, socket) do
{:noreply, assign(socket, :messages, append_message(socket.assigns.messages, message))}
{:noreply, put_realtime_message(socket, message)}
end
def handle_info({:position_updated, user_id, position, evidence}, socket) do
@ -155,7 +156,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:ok, message} ->
{:noreply,
socket
|> assign(:messages, append_message(socket.assigns.messages, message))
|> put_realtime_message(message)
|> push_event("reset-message-form", %{id: "message-form"})}
{:error, reason} ->
@ -177,7 +178,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:noreply,
socket
|> assign(:messages, older ++ socket.assigns.messages)
|> assign(:messages_cursor, page.next_cursor)}
|> assign(:messages_cursor, page.next_cursor)
|> update(:message_window_limit, &(&1 + length(older)))}
end
def handle_event("start-tracking", _, socket) do
@ -427,6 +429,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
)
|> assign(:messages, messages_page.entries)
|> assign(:messages_cursor, messages_page.next_cursor)
|> assign(:message_window_limit, max(length(messages_page.entries), @initial_message_window))
|> assign(:report_message_id, nil)
|> assign(:positions, positions)
|> assign(:tracking_active, tracking_active)
@ -446,6 +449,22 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
if Enum.any?(messages, &(&1.id == message.id)), do: messages, else: messages ++ [message]
end
defp put_realtime_message(socket, message) do
messages = append_message(socket.assigns.messages, message)
window_limit = socket.assigns.message_window_limit
if length(messages) > window_limit do
messages = Enum.take(messages, -window_limit)
oldest = hd(messages)
socket
|> assign(:messages, messages)
|> assign(:messages_cursor, Pagination.encode(oldest.inserted_at, oldest.id))
else
assign(socket, :messages, messages)
end
end
defp report_form do
to_form(%{"reason" => "dangerous_request", "details" => ""}, as: :report)
end
@ -570,8 +589,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
latitude: point.latitude,
longitude: point.longitude,
exact: true,
title: gettext("Shared live location"),
location: gettext("Active match only")
title: gettext("Shared location"),
location:
gettext(
"Last update: %{time}",
time: Calendar.strftime(point.captured_at, "%d.%m.%Y, %H:%M:%S UTC")
)
}
end)
end
@ -704,7 +727,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
:for={identity <- @request.requester.social_identities}
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow"
rel="noopener noreferrer nofollow ugc"
class="badge badge-warning badge-outline"
>
{identity.provider} · {if identity.verified_at,
@ -901,7 +924,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
:for={identity <- @assignment.helper.social_identities}
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow"
rel="noopener noreferrer nofollow ugc"
class="badge badge-warning badge-outline"
>
{identity.provider} · {if identity.verified_at,
@ -982,7 +1005,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
}
href={@assignment.helper.tip_url}
target="_blank"
rel="noopener noreferrer nofollow"
rel="noopener noreferrer nofollow ugc"
class="btn btn-outline w-full"
>
{gettext("Optional thank-you link")}

View File

@ -3,13 +3,19 @@ defmodule WhoNeedHelpWeb.Router do
import WhoNeedHelpWeb.UserAuth
@secure_browser_headers %{
"content-security-policy" =>
"default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'",
"permissions-policy" => "geolocation=(self), camera=(), microphone=(), payment=(), usb=()"
}
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 :put_secure_browser_headers, @secure_browser_headers
plug :put_content_security_policy
plug :fetch_current_scope_for_user
plug :put_authenticated_cache_policy
@ -24,7 +30,7 @@ defmodule WhoNeedHelpWeb.Router do
plug :accepts, ["json"]
plug :fetch_session
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :put_secure_browser_headers, @secure_browser_headers
plug :put_content_security_policy
plug :fetch_current_scope_for_user
plug :put_authenticated_cache_policy
@ -104,7 +110,8 @@ defmodule WhoNeedHelpWeb.Router do
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page
post "/users/settings/confirm-email", UserSettingsController, :confirm_email
get "/auth/social/:provider", SocialOAuthController, :request
get "/auth/social/:provider/callback", SocialOAuthController, :callback
end
@ -135,7 +142,6 @@ defmodule WhoNeedHelpWeb.Router 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

View File

@ -15,7 +15,8 @@ defmodule WhoNeedHelpWeb.UserAuth do
@remember_me_options [
sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
same_site: "Lax"
same_site: "Lax",
secure: Application.compile_env(:who_need_help, :secure_cookies, false)
]
# How old the session token should be before a new one is issued. When a request is made

View File

@ -15,7 +15,8 @@ defmodule WhoNeedHelpWeb.ValidationMessages do
dgettext_noop("errors", "must be in the future"),
dgettext_noop("errors", "select an activity category"),
dgettext_noop("errors", "must be on or before the start time"),
dgettext_noop("errors", "must be a complete http(s) URL"),
dgettext_noop("errors", "must be a complete HTTPS URL"),
dgettext_noop("errors", "must be a full HTTPS URL"),
dgettext_noop("errors", "must be a GitHub profile URL"),
dgettext_noop("errors", "you must confirm that you are 18+ and accept the rules"),
dgettext_noop("errors", "must have the @ sign and no spaces"),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -116,12 +116,12 @@ msgstr ""
msgid "contains an invalid field definition"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:22
#: lib/who_need_help_web/validation_messages.ex:23
#, elixir-autogen, elixir-format
msgid "did not change"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:23
#: lib/who_need_help_web/validation_messages.ex:24
#, elixir-autogen, elixir-format
msgid "does not match password"
msgstr ""
@ -131,16 +131,11 @@ msgstr ""
msgid "field keys must be unique"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:19
#: lib/who_need_help_web/validation_messages.ex:20
#, elixir-autogen, elixir-format
msgid "must be a GitHub profile URL"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:18
#, elixir-autogen, elixir-format
msgid "must be a complete http(s) URL"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:12
#, elixir-autogen, elixir-format
msgid "must be an object with a fields array"
@ -166,7 +161,7 @@ msgstr ""
msgid "must contain non-empty localized descriptions"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:21
#: lib/who_need_help_web/validation_messages.ex:22
#, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces"
msgstr ""
@ -186,7 +181,17 @@ msgstr ""
msgid "select exactly one report target"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:20
#: lib/who_need_help_web/validation_messages.ex:21
#, elixir-autogen, elixir-format
msgid "you must confirm that you are 18+ and accept the rules"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:18
#, elixir-autogen, elixir-format
msgid "must be a complete HTTPS URL"
msgstr "must be a complete HTTPS URL"
#: lib/who_need_help_web/validation_messages.ex:19
#, elixir-autogen, elixir-format
msgid "must be a full HTTPS URL"
msgstr "must be a full HTTPS URL"

View File

@ -113,12 +113,12 @@ msgstr ""
msgid "contains an invalid field definition"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:22
#: lib/who_need_help_web/validation_messages.ex:23
#, elixir-autogen, elixir-format
msgid "did not change"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:23
#: lib/who_need_help_web/validation_messages.ex:24
#, elixir-autogen, elixir-format
msgid "does not match password"
msgstr ""
@ -128,16 +128,11 @@ msgstr ""
msgid "field keys must be unique"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:19
#: lib/who_need_help_web/validation_messages.ex:20
#, elixir-autogen, elixir-format
msgid "must be a GitHub profile URL"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:18
#, elixir-autogen, elixir-format
msgid "must be a complete http(s) URL"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:12
#, elixir-autogen, elixir-format
msgid "must be an object with a fields array"
@ -163,7 +158,7 @@ msgstr ""
msgid "must contain non-empty localized descriptions"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:21
#: lib/who_need_help_web/validation_messages.ex:22
#, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces"
msgstr ""
@ -183,7 +178,17 @@ msgstr ""
msgid "select exactly one report target"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:20
#: lib/who_need_help_web/validation_messages.ex:21
#, elixir-autogen, elixir-format
msgid "you must confirm that you are 18+ and accept the rules"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:18
#, elixir-autogen, elixir-format
msgid "must be a complete HTTPS URL"
msgstr ""
#: lib/who_need_help_web/validation_messages.ex:19
#, elixir-autogen, elixir-format
msgid "must be a full HTTPS URL"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@ -115,12 +115,12 @@ msgstr "должно быть равно %{number}"
msgid "contains an invalid field definition"
msgstr "содержит неверное определение поля"
#: lib/who_need_help_web/validation_messages.ex:22
#: lib/who_need_help_web/validation_messages.ex:23
#, elixir-autogen, elixir-format
msgid "did not change"
msgstr "не изменилось"
#: lib/who_need_help_web/validation_messages.ex:23
#: lib/who_need_help_web/validation_messages.ex:24
#, elixir-autogen, elixir-format
msgid "does not match password"
msgstr "не совпадает с паролем"
@ -130,16 +130,11 @@ msgstr "не совпадает с паролем"
msgid "field keys must be unique"
msgstr "ключи полей должны быть уникальными"
#: lib/who_need_help_web/validation_messages.ex:19
#: lib/who_need_help_web/validation_messages.ex:20
#, elixir-autogen, elixir-format
msgid "must be a GitHub profile URL"
msgstr "должно быть URL профиля GitHub"
#: lib/who_need_help_web/validation_messages.ex:18
#, elixir-autogen, elixir-format
msgid "must be a complete http(s) URL"
msgstr "должно быть полным URL с http(s)"
#: lib/who_need_help_web/validation_messages.ex:12
#, elixir-autogen, elixir-format
msgid "must be an object with a fields array"
@ -165,7 +160,7 @@ msgstr "должно содержать хотя бы одно непустое
msgid "must contain non-empty localized descriptions"
msgstr "должно содержать непустые локализованные описания"
#: lib/who_need_help_web/validation_messages.ex:21
#: lib/who_need_help_web/validation_messages.ex:22
#, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces"
msgstr "должно содержать знак @ и не содержать пробелов"
@ -185,7 +180,17 @@ msgstr "выберите категорию активности"
msgid "select exactly one report target"
msgstr "выберите ровно один объект жалобы"
#: lib/who_need_help_web/validation_messages.ex:20
#: lib/who_need_help_web/validation_messages.ex:21
#, elixir-autogen, elixir-format
msgid "you must confirm that you are 18+ and accept the rules"
msgstr "подтвердите, что вам исполнилось 18 лет и вы принимаете правила"
#: lib/who_need_help_web/validation_messages.ex:18
#, elixir-autogen, elixir-format
msgid "must be a complete HTTPS URL"
msgstr "должно быть полным HTTPS URL"
#: lib/who_need_help_web/validation_messages.ex:19
#, elixir-autogen, elixir-format
msgid "must be a full HTTPS URL"
msgstr "должно быть полным HTTPS URL"

File diff suppressed because it is too large Load Diff

View File

@ -115,12 +115,12 @@ msgstr "має дорівнювати %{number}"
msgid "contains an invalid field definition"
msgstr "містить неправильне визначення поля"
#: lib/who_need_help_web/validation_messages.ex:22
#: lib/who_need_help_web/validation_messages.ex:23
#, elixir-autogen, elixir-format
msgid "did not change"
msgstr "не змінилося"
#: lib/who_need_help_web/validation_messages.ex:23
#: lib/who_need_help_web/validation_messages.ex:24
#, elixir-autogen, elixir-format
msgid "does not match password"
msgstr "не збігається з паролем"
@ -130,16 +130,11 @@ msgstr "не збігається з паролем"
msgid "field keys must be unique"
msgstr "ключі полів мають бути унікальними"
#: lib/who_need_help_web/validation_messages.ex:19
#: lib/who_need_help_web/validation_messages.ex:20
#, elixir-autogen, elixir-format
msgid "must be a GitHub profile URL"
msgstr "має бути URL-адресою профілю GitHub"
#: lib/who_need_help_web/validation_messages.ex:18
#, elixir-autogen, elixir-format
msgid "must be a complete http(s) URL"
msgstr "має бути повною URL-адресою з http(s)"
#: lib/who_need_help_web/validation_messages.ex:12
#, elixir-autogen, elixir-format
msgid "must be an object with a fields array"
@ -165,7 +160,7 @@ msgstr "має містити щонайменше одну непорожню
msgid "must contain non-empty localized descriptions"
msgstr "має містити непорожні локалізовані описи"
#: lib/who_need_help_web/validation_messages.ex:21
#: lib/who_need_help_web/validation_messages.ex:22
#, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces"
msgstr "має містити знак @ і не містити пробілів"
@ -185,7 +180,17 @@ msgstr "виберіть категорію активності"
msgid "select exactly one report target"
msgstr "виберіть рівно один об’єкт скарги"
#: lib/who_need_help_web/validation_messages.ex:20
#: lib/who_need_help_web/validation_messages.ex:21
#, elixir-autogen, elixir-format
msgid "you must confirm that you are 18+ and accept the rules"
msgstr "підтвердьте, що вам виповнилося 18 років і ви приймаєте правила"
#: lib/who_need_help_web/validation_messages.ex:18
#, elixir-autogen, elixir-format
msgid "must be a complete HTTPS URL"
msgstr "має бути повною HTTPS URL-адресою"
#: lib/who_need_help_web/validation_messages.ex:19
#, elixir-autogen, elixir-format
msgid "must be a full HTTPS URL"
msgstr "має бути повною HTTPS URL-адресою"

View File

@ -6,8 +6,9 @@ ANDROID_ENV="$ROOT/.env"
TEST_ENV="$ROOT/.env.android-test"
run_id=$(date -u +%Y%m%d%H%M%S)-$$
android_api=${WNH_ANDROID_TEST_API:-37.0}
# 1G is measured against the complete API 30/34/37 suite. It can still be
# overridden for a future test that intentionally stores more device data.
# 1G is measured against the API 30/34/37 suite and is also exercised by the
# API 24 minimum-SDK probe. It can still be overridden for a future test that
# intentionally stores more device data.
android_data_partition_size=${WNH_ANDROID_TEST_DATA_PARTITION_SIZE:-1G}
if ! printf '%s\n' "$android_data_partition_size" |
@ -17,6 +18,9 @@ if ! printf '%s\n' "$android_data_partition_size" |
fi
case "$android_api" in
24)
android_system_image="system-images/android-24/google_apis/x86_64"
;;
30)
android_system_image="system-images/android-30/google_apis/x86_64"
;;
@ -27,7 +31,7 @@ case "$android_api" in
android_system_image="system-images/android-37.0/google_apis_ps16k/x86_64"
;;
*)
echo "WNH_ANDROID_TEST_API must be one of: 30, 34, 37.0." >&2
echo "WNH_ANDROID_TEST_API must be one of: 24, 30, 34, 37.0." >&2
exit 1
;;
esac
@ -100,6 +104,12 @@ cleanup() {
docker exec "$container" adb logcat -d > "$output/logcat.txt" 2>&1 || true
docker exec "$container" adb shell dumpsys activity services \
org.whoneedhelp.mobile.debug > "$output/services.txt" 2>&1 || true
docker exec "$container" adb shell dumpsys notification --noredact \
> "$output/notifications.txt" 2>&1 || true
docker exec "$container" adb shell uiautomator dump /sdcard/window.xml \
> "$output/window-dump.txt" 2>&1 || true
docker exec "$container" adb shell cat /sdcard/window.xml \
> "$output/window.xml" 2>&1 || true
docker logs "$container" > "$output/emulator.log" 2>&1 || true
fi
@ -151,7 +161,11 @@ docker exec "$container" adb shell input keyevent 82
docker exec "$container" adb shell settings put global window_animation_scale 0
docker exec "$container" adb shell settings put global transition_animation_scale 0
docker exec "$container" adb shell settings put global animator_duration_scale 0
if [ "$android_api" = "24" ]; then
docker exec "$container" adb shell settings put secure location_mode 3
else
docker exec "$container" adb shell cmd location set-location-enabled true
fi
docker exec "$container" adb emu geo fix -122.084000 37.422000
docker exec "$container" adb install -r \
/opt/who-need-help/who-need-help-debug.apk

View File

@ -2,11 +2,11 @@
set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
matrix=${WNH_ANDROID_TEST_API_MATRIX:-"30 34 37.0"}
matrix=${WNH_ANDROID_TEST_API_MATRIX:-"24 30 34 37.0"}
for api in $matrix; do
case "$api" in
30|34|37.0) ;;
24|30|34|37.0) ;;
*)
echo "WNH_ANDROID_TEST_API_MATRIX contains unsupported API: $api" >&2
exit 1

View File

@ -19,7 +19,7 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \
LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS \
LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS \
LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \
HTTP_PORT POSTGRES_DB; do
HTTP_PORT POSTGRES_DB METRICS_TOKEN; do
if [[ -z "${!name:-}" ]]; then
echo "$name is missing from .env.load" >&2
exit 1
@ -184,6 +184,7 @@ sample_readiness() {
observed_at=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
body_file="$output_dir/readiness-body.$$"
error_file="$output_dir/readiness-error.$$"
metrics_headers_file="$output_dir/metrics-headers.$$"
set +e
status=$(
curl --silent --show-error \
@ -206,15 +207,55 @@ sample_readiness() {
body=
fi
set +e
metrics_status=$(
curl --silent --show-error \
--max-time "$LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS" \
--header "Host: $LOAD_HOST" \
--header "Authorization: Bearer $METRICS_TOKEN" \
--dump-header "$metrics_headers_file" \
--output /dev/null \
--write-out '%{http_code}' \
"http://localhost:$HTTP_PORT/metrics" 2>>"$error_file"
)
metrics_curl_status=$?
set -e
if [[ "$metrics_curl_status" -ne 0 ]]; then
metrics_status=000
fi
node=$(
awk '
BEGIN {IGNORECASE = 1}
/^x-wnh-node:/ {
sub(/^[^:]+:[[:space:]]*/, "")
sub(/\r$/, "")
print
exit
}
' "$metrics_headers_file" 2>/dev/null || true
)
error=$(tr -d '\n' <"$error_file")
unlink "$body_file" 2>/dev/null || true
unlink "$error_file" 2>/dev/null || true
unlink "$metrics_headers_file" 2>/dev/null || true
jq -cn \
--arg observed_at "$observed_at" \
--arg status "$status" \
--arg metrics_status "$metrics_status" \
--arg node "$node" \
--arg body "$body" \
--arg error "$error" \
'{observed_at: $observed_at, status: $status, body: $body, error: $error}' \
'{
observed_at: $observed_at,
status: $status,
metrics_status: $metrics_status,
node: $node,
body: $body,
error: $error
}' \
>>"$probe_log"
sleep "$LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS"
done
@ -338,7 +379,7 @@ mapfile -t original_web_ids < <(service_ids web)
for container_id in "${original_web_ids[@]}"; do
assert_scope "$container_id" web
{
docker stop --time 30 "$container_id"
docker stop --timeout 30 "$container_id"
docker rm "$container_id"
"${compose[@]}" up -d --no-deps --scale "web=$LOAD_WEB_REPLICAS" web
} >>"$output_dir/web-replacements.txt"
@ -350,7 +391,7 @@ mapfile -t original_worker_ids < <(service_ids worker)
for container_id in "${original_worker_ids[@]}"; do
assert_scope "$container_id" worker
{
docker stop --time 30 "$container_id"
docker stop --timeout 30 "$container_id"
docker rm "$container_id"
"${compose[@]}" up -d --no-deps --scale "worker=$LOAD_WORKER_REPLICAS" worker
} >>"$output_dir/worker-replacements.txt"
@ -438,8 +479,8 @@ probe_pid=
jq -s '{
samples: length,
failures: (map(select(.status != "200")) | length),
nodes: (map(.body | fromjson? | .node) | map(select(. != null)) | unique)
failures: (map(select(.status != "200" or .metrics_status != "200")) | length),
nodes: (map(.node) | map(select(. != null and . != "")) | unique)
}' "$probe_log" >"$output_dir/readiness-summary.json"
if ! jq -e '.samples > 0 and .failures == 0 and (.nodes | length) >= 2' \

View File

@ -213,7 +213,16 @@ echo "Linting the Helm chart"
"$ROOT/deploy/helm/who-need-help"
echo "Scanning only tracked and non-ignored source files"
git ls-files --cached --others --exclude-standard -z >"$scan_list"
# The single-quoted program must expand $path inside the child shell.
# shellcheck disable=SC2016
git ls-files --cached --others --exclude-standard -z |
xargs -0 -r sh -c '
for path do
if [ -f "$path" ]; then
printf "%s\0" "$path"
fi
done
' sh >"$scan_list"
tar --null --no-recursion --files-from="$scan_list" --create --file="$scan_tar"
tar --extract --file="$scan_tar" --directory "$scan_dir"
"$ROOT/.tools/bin/helm" template who-need-help \

View File

@ -4,7 +4,7 @@ defmodule WhoNeedHelp.AccountsTest do
alias WhoNeedHelp.Accounts
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.Accounts.{User, UserToken}
alias WhoNeedHelp.Accounts.{SocialIdentity, User, UserToken}
describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do
@ -14,6 +14,7 @@ defmodule WhoNeedHelp.AccountsTest do
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)
assert %User{id: ^id} = Accounts.get_user_by_email(" #{String.upcase(user.email)} ")
end
end
@ -32,6 +33,12 @@ defmodule WhoNeedHelp.AccountsTest do
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(
" #{String.upcase(user.email)} ",
valid_user_password()
)
end
test "does not authenticate a suspended user with a valid password" do
@ -140,6 +147,67 @@ defmodule WhoNeedHelp.AccountsTest do
end
end
describe "profile URL validation" do
test "accepts only encrypted thank-you and social profile URLs" do
user = user_fixture()
profile_attrs = %{
"display_name" => user.display_name,
"locale" => user.locale,
"location_visibility" => user.location_visibility,
"direct_message_policy" => user.direct_message_policy
}
assert User.profile_changeset(
user,
Map.put(profile_attrs, "tip_url", "https://example.com/thank-you")
).valid?
refute User.profile_changeset(
user,
Map.put(profile_attrs, "tip_url", "http://example.com/thank-you")
).valid?
social = %SocialIdentity{user_id: Ecto.UUID.generate()}
social_attrs = %{"provider" => "other", "profile_url" => "https://example.com/profile"}
assert SocialIdentity.changeset(social, social_attrs).valid?
refute SocialIdentity.changeset(
social,
Map.put(social_attrs, "profile_url", "http://example.com/profile")
).valid?
end
test "rejects a thank-you URL that cannot fit its database column" do
user = user_fixture()
changeset =
User.profile_changeset(user, %{
"display_name" => user.display_name,
"locale" => user.locale,
"location_visibility" => user.location_visibility,
"direct_message_policy" => user.direct_message_policy,
"tip_url" => "https://example.com/#{String.duplicate("x", 240)}"
})
assert "should be at most 255 character(s)" in errors_on(changeset).tip_url
end
test "rejects a social URL that cannot fit its database column" do
changeset =
SocialIdentity.changeset(
%SocialIdentity{user_id: Ecto.UUID.generate()},
%{
"provider" => "other",
"profile_url" => "https://example.com/#{String.duplicate("x", 240)}"
}
)
assert "should be at most 255 character(s)" in errors_on(changeset).profile_url
end
end
describe "change_user_email/3" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
@ -364,6 +432,10 @@ defmodule WhoNeedHelp.AccountsTest do
end
describe "login_user_by_magic_link/1" do
test "returns not found for a syntactically invalid token" do
assert {:error, :not_found} = Accounts.login_user_by_magic_link("%not-base64%")
end
test "confirms user and expires tokens" do
user = unconfirmed_user_fixture()
refute user.confirmed_at
@ -384,6 +456,24 @@ defmodule WhoNeedHelp.AccountsTest do
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end
test "concurrent requests consume an unconfirmed user's token exactly once" do
user = unconfirmed_user_fixture()
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
results =
1..2
|> Task.async_stream(
fn _ -> Accounts.login_user_by_magic_link(encoded_token) end,
max_concurrency: 2,
ordered: false
)
|> Enum.map(fn {:ok, result} -> result end)
assert Enum.count(results, &match?({:ok, {%User{}, _tokens}}, &1)) == 1
assert Enum.count(results, &(&1 == {:error, :not_found})) == 1
assert Accounts.get_user!(user.id).confirmed_at
end
test "rejects a suspended user and consumes the magic link" do
user = user_fixture()
@ -419,6 +509,54 @@ defmodule WhoNeedHelp.AccountsTest do
end
end
describe "delete_expired_user_tokens/1" do
test "prunes each token context only after its configured validity" do
user = user_fixture()
now = DateTime.utc_now(:second)
expired_login =
Repo.insert!(%UserToken{
user_id: user.id,
token: :crypto.strong_rand_bytes(32),
context: "login",
sent_to: user.email,
inserted_at: DateTime.add(now, -16, :minute)
})
active_login =
Repo.insert!(%UserToken{
user_id: user.id,
token: :crypto.strong_rand_bytes(32),
context: "login",
sent_to: user.email,
inserted_at: DateTime.add(now, -14, :minute)
})
expired_change =
Repo.insert!(%UserToken{
user_id: user.id,
token: :crypto.strong_rand_bytes(32),
context: "change:#{user.email}",
sent_to: unique_user_email(),
inserted_at: DateTime.add(now, -8, :day)
})
expired_session =
Repo.insert!(%UserToken{
user_id: user.id,
token: :crypto.strong_rand_bytes(32),
context: "session",
inserted_at: DateTime.add(now, -15, :day)
})
assert {3, _} = Accounts.delete_expired_user_tokens(now)
refute Repo.get(UserToken, expired_login.id)
refute Repo.get(UserToken, expired_change.id)
refute Repo.get(UserToken, expired_session.id)
assert Repo.get(UserToken, active_login.id)
end
end
describe "get_user_by_session_token/1 moderation boundary" do
test "never authenticates a suspended user even if a token still exists" do
user = user_fixture()

View File

@ -88,12 +88,113 @@ defmodule WhoNeedHelp.ActivitiesTest do
Activities.request_to_join(context.outsider_scope, activity.id)
end
test "invalid participant identifiers are rejected", context do
test "global activity events carry only the bounded index projection", context do
:ok = Activities.subscribe()
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
assert_receive {:activity_created, created}
assert created.id == activity.id
assert created.approved_participant_count == 1
assert %Ecto.Association.NotLoaded{} = created.participants
assert %Ecto.Association.NotLoaded{} = created.messages
assert %Ecto.Association.NotLoaded{} = created.creator.social_identities
:ok = Activities.subscribe_user(context.participant_scope)
{:ok, _request} = Activities.request_to_join(context.participant_scope, activity.id)
assert_receive {:activity_updated, updated}
assert updated.id == activity.id
assert updated.approved_participant_count == 1
assert %Ecto.Association.NotLoaded{} = updated.participants
assert %Ecto.Association.NotLoaded{} = updated.messages
assert_receive {:my_activity_updated, participant_update, true}
assert participant_update.id == activity.id
end
test "invalid and missing client identifiers are rejected without raising", context do
assert {:error, :not_found} =
Activities.approve_participant(context.organizer_scope, "not-a-uuid")
assert {:error, :not_found} =
Activities.decline_participant(context.organizer_scope, "not-a-uuid")
missing_id = Ecto.UUID.generate()
assert {:error, :not_found} =
Activities.approve_participant(context.organizer_scope, missing_id)
assert {:error, :not_found} =
Activities.decline_participant(context.organizer_scope, missing_id)
assert {:error, :not_found} =
Activities.request_to_join(context.participant_scope, missing_id)
assert {:error, :not_found} =
Activities.send_message(context.participant_scope, missing_id, %{"body" => "No"})
assert {:error, :not_found} =
Activities.cancel_activity(context.organizer_scope, missing_id)
assert {:error, :not_found} =
Activities.complete_activity(context.organizer_scope, missing_id)
for invalid_id <- ["not-a-uuid", missing_id] do
assert {:error, :not_found} =
Activities.leave_activity(context.participant_scope, invalid_id)
assert {:error, :not_found} =
Activities.request_to_join(context.participant_scope, invalid_id)
assert {:error, :not_found} =
Activities.send_message(context.participant_scope, invalid_id, %{"body" => "No"})
assert {:error, :not_found} =
Activities.cancel_activity(context.organizer_scope, invalid_id)
assert {:error, :not_found} =
Activities.complete_activity(context.organizer_scope, invalid_id)
end
end
test "activity location labels are validated before the database write", context do
assert {:error, changeset} =
Activities.create_activity(
context.organizer_scope,
Map.put(context.attrs, "location_label", String.duplicate("x", 256))
)
assert "should be at most 255 character(s)" in errors_on(changeset).location_label
end
test "activity capacity outside the PostgreSQL integer range is rejected before insert",
context do
assert {:error, changeset} =
Activities.create_activity(
context.organizer_scope,
Map.put(context.attrs, "capacity", 2_147_483_648)
)
assert "must be less than or equal to 2147483647" in errors_on(changeset).capacity
end
test "forged coordinate types become validation errors instead of raising", context do
assert {:error, changeset} =
Activities.create_activity(
context.organizer_scope,
Map.put(context.attrs, "latitude", %{"nested" => "value"})
)
assert "select a valid location" in errors_on(changeset).location
end
test "forged category filters return no activities instead of raising", context do
assert {:ok, _activity} =
Activities.create_activity(context.organizer_scope, context.attrs)
assert [] ==
Activities.list_open_activities(context.participant_scope, %{
"category_id" => "not-a-uuid"
})
end
test "unapproved viewers receive no private chat or pending participant data", context do

View File

@ -79,6 +79,61 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert {:error, :not_found} = Catalog.unvote(context.requester_scope, "not-a-uuid")
end
test "invalid request workflow identifiers and handover codes are rejected", context do
missing_id = Ecto.UUID.generate()
for id <- ["not-a-uuid", missing_id] do
assert {:error, :not_found} = Help.accept_request(context.helper_scope, id)
assert {:error, :not_found} = Help.cancel_request(context.requester_scope, id)
assert {:error, :not_found} = Help.start_assignment(context.helper_scope, id)
assert {:error, :not_found} = Help.confirm_completion(context.helper_scope, id)
assert {:error, :not_found} = Help.withdraw_assignment(context.helper_scope, id)
assert {:error, :not_found} =
Help.verify_handover(context.helper_scope, id, "123456")
end
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
for code <- [%{"nested" => "123456"}, "12345", "1234567", "abcdef"] do
assert {:error, :invalid_code} =
Help.verify_handover(context.helper_scope, assignment.id, code)
end
end
test "request location labels are validated before the database write", context do
assert {:error, changeset} =
Help.create_request(
context.requester_scope,
Map.put(context.request_attrs, "location_label", String.duplicate("x", 256))
)
assert "should be at most 255 character(s)" in errors_on(changeset).location_label
end
test "forged request coordinate types become validation errors instead of raising", context do
assert {:error, changeset} =
Help.create_request(
context.requester_scope,
Map.put(context.request_attrs, "longitude", %{"nested" => "value"})
)
assert "select a valid location" in errors_on(changeset).location
end
test "forged filters return no requests instead of raising", context do
assert [] ==
Help.list_open_requests(context.helper_scope, %{
"category_id" => "not-a-uuid"
})
assert [] ==
Help.list_open_requests(context.helper_scope, %{
"urgency" => "impossible"
})
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)
@ -211,6 +266,21 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert oldest.next_cursor == nil
end
test "chat and review return not found for a stale assignment reference", context do
stale_assignment = %WhoNeedHelp.Help.Assignment{id: Ecto.UUID.generate()}
assert {:error, :not_found} =
Messaging.send_message(context.helper_scope, stale_assignment, %{
"body" => "This assignment no longer exists"
})
assert {:error, :not_found} =
Trust.submit_review(context.helper_scope, stale_assignment, %{
"rating" => 5,
"comment" => "This assignment no longer exists"
})
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)
@ -229,6 +299,21 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
refute Repo.get(Position, position.id)
end
test "forged tracking coordinate types become changeset errors instead of raising", 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)
assert {:error, changeset} =
Tracking.update_position(context.helper_scope, assignment, %{
"latitude" => %{"nested" => "value"},
"longitude" => 30.52,
"accuracy_meters" => 12.0
})
assert "is invalid" in errors_on(changeset).position
end
test "concurrent tracking stops serialize and broadcast once", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)

View File

@ -62,6 +62,17 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert {:error, :not_found} = Trust.unblock(context.requester_scope, "not-a-uuid")
end
test "invalid report target identifiers are rejected without a query cast failure", context do
for target <- ~w(request_id assignment_id message_id activity_id activity_message_id) do
assert {:error, :not_found} =
Trust.report(context.requester_scope, %{
target => "not-a-uuid",
"reason" => "unsafe",
"details" => "Malformed target identifiers must not reach Ecto queries."
})
end
end
test "forged moderation identifiers return not found without crashing", context do
moderator =
user_fixture(display_name: "Moderator")
@ -157,6 +168,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert entry.unique_people == 1
assert entry.verified_people == 1
assert entry.location_supported_people == 1
assert is_nil(entry.user.email)
assert is_nil(entry.user.hashed_password)
assert Repo.exists?(
from signal in AbuseSignal,
@ -240,6 +253,14 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert {:error, changeset} = Help.create_request(context.requester_scope, invalid)
assert "pickup_status has an invalid value" in errors_on(changeset).structured_data
nested =
Map.put(context.attrs, "structured_data", %{
"pickup_status" => %{"unexpected" => "reserved"}
})
assert {:error, changeset} = Help.create_request(context.requester_scope, nested)
assert "pickup_status has an invalid value" in errors_on(changeset).structured_data
end
test "help creation rejects missing safety consent and non-help categories", context do
@ -284,6 +305,15 @@ defmodule WhoNeedHelp.TrustSafetyTest do
)
end
test "malformed runtime rate-limit configuration never crashes a request", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, ["not", "a", "map"])
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end)
assert {:ok, :not_configured} =
RateLimiter.check(:test_action, context.requester.id)
end
test "restricted accounts cannot perform trust-sensitive actions", context do
context.helper
|> WhoNeedHelp.Accounts.User.moderation_changeset(%{moderation_status: :restricted})
@ -293,14 +323,16 @@ defmodule WhoNeedHelp.TrustSafetyTest do
Help.create_request(context.helper_scope, context.attrs)
end
test "hidden request coordinates are absent publicly but exact for a participant", context do
test "hidden request coordinates remain hidden from an accepted helper", context do
hidden_attrs = Map.put(context.attrs, "location_visibility", "hidden")
{:ok, request} = Help.create_request(context.requester_scope, hidden_attrs)
{:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
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 is_nil(Help.request_coordinates(context.helper_scope, request))
assert %{latitude: 50.4501, longitude: 30.5234, exact: true} =
Help.request_coordinates(context.requester_scope, request)

View File

@ -34,6 +34,7 @@ defmodule WhoNeedHelpWeb.MetricsControllerTest do
]
assert get_resp_header(conn, "cache-control") == ["no-store"]
assert get_resp_header(conn, "x-wnh-node") == [to_string(node())]
assert body =~ "# TYPE who_need_help_http_requests_total counter"
assert body =~ "who_need_help_http_requests_total "
assert body =~ "who_need_help_http_request_duration_microseconds_total "

View File

@ -14,6 +14,10 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
assert content_security_policy =~ "frame-ancestors 'none'"
assert content_security_policy =~ "https://tile.openstreetmap.org"
assert get_resp_header(conn, "permissions-policy") == [
"geolocation=(self), camera=(), microphone=(), payment=(), usb=()"
]
assert html =~
~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png")
@ -34,6 +38,22 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
) == 1
end
test "Phoenix logs filter authentication and handover secrets" do
assert Phoenix.Logger.filter_values(%{
"password" => "not-logged",
"_csrf_token" => "not-logged",
"token" => "not-logged",
"handover" => %{"code" => "123456"},
"email" => "visible@example.test"
}) == %{
"password" => "[FILTERED]",
"_csrf_token" => "[FILTERED]",
"token" => "[FILTERED]",
"handover" => %{"code" => "[FILTERED]"},
"email" => "visible@example.test"
}
end
test "GET / selects Russian locale", %{conn: conn} do
conn = get(conn, ~p"/?locale=ru")
assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется."

View File

@ -2,11 +2,13 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
use WhoNeedHelpWeb.ConnCase, async: true
import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions
describe "GET /users/register" do
test "renders registration page", %{conn: conn} do
conn = get(conn, ~p"/users/register")
response = html_response(conn, 200)
assert get_resp_header(conn, "cache-control") == ["no-store"]
assert response =~ "Register"
assert response =~ ~p"/users/log-in"
assert response =~ ~p"/users/register"
@ -20,6 +22,28 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
end
describe "POST /users/register" do
test "returns bad request for malformed parameters", %{conn: conn} do
conn = post(conn, ~p"/users/register", %{"user" => "invalid"})
assert html_response(conn, 400) =~ "The registration form is invalid."
assert get_resp_header(conn, "cache-control") == ["no-store"]
end
test "renders validation errors when email has a forged nested type", %{conn: conn} do
conn =
post(conn, ~p"/users/register", %{
"user" => %{
"email" => %{"nested" => "value"},
"display_name" => "Malformed email",
"locale" => "en",
"terms_accepted" => "true"
}
})
assert html_response(conn, 200) =~ "blank"
refute get_session(conn, :user_token)
end
@tag :capture_log
test "creates account but does not log in", %{conn: conn} do
email = unique_user_email()
@ -33,7 +57,25 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
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/
"If this address can be registered or signed in, login instructions will arrive shortly."
assert_email_sent()
end
test "does not reveal whether a valid email is already registered", %{conn: conn} do
user = user_fixture()
params = %{"user" => valid_user_attributes(email: user.email)}
existing_conn = post(conn, ~p"/users/register", params)
assert redirected_to(existing_conn) == ~p"/users/log-in"
assert existing_conn.assigns.flash["info"] ==
"If this address can be registered or signed in, login instructions will arrive shortly."
assert_email_sent(fn email ->
Enum.any?(email.to, fn {_name, address} -> address == user.email end)
end)
end
test "render errors for invalid data", %{conn: conn} do

View File

@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
use WhoNeedHelpWeb.ConnCase, async: false
import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions
alias WhoNeedHelp.Accounts
setup do
@ -12,9 +13,12 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
test "renders login page", %{conn: conn} do
conn = get(conn, ~p"/users/log-in")
response = html_response(conn, 200)
assert get_resp_header(conn, "cache-control") == ["no-store"]
assert response =~ "Log in"
assert response =~ ~p"/users/register"
assert response =~ "Log in with email"
assert response =~ ~s(id="magic-link-fragment-form")
assert response =~ ~s(hidden)
end
test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do
@ -41,38 +45,6 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
end
end
describe "GET /users/log-in/:token" do
test "renders confirmation page for unconfirmed user", %{conn: conn, unconfirmed_user: user} do
token =
extract_user_token(fn url ->
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)
@ -189,7 +161,16 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
end
describe "POST /users/log-in - magic link" do
test "returns bad request for malformed parameters", %{conn: conn} do
conn = post(conn, ~p"/users/log-in", %{"user" => %{"email" => ["invalid"]}})
assert html_response(conn, 400) =~ "The sign-in form is invalid."
assert get_resp_header(conn, "cache-control") == ["no-store"]
end
test "sends magic link email when user exists", %{conn: conn, user: user} do
assert_email_sent()
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"email" => user.email}
@ -197,6 +178,11 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
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"
assert_email_sent(fn email ->
email.text_body =~ "/users/log-in#token=" and
not String.contains?(email.text_body, "/users/log-in/")
end)
end
test "does not send a magic link for a suspended user", %{conn: conn, user: user} do

View File

@ -3,6 +3,7 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
alias WhoNeedHelp.Accounts
import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions
setup :register_and_log_in_user
@ -70,18 +71,29 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert response =~ "Settings"
assert response =~ "should be at least 12 character(s)"
assert response =~ "does not match password"
refute response =~ ~s(value="too short")
refute response =~ ~s(value="does not match")
assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token)
end
end
describe "PUT /users/settings (change email form)" do
test "returns bad request for malformed parameters", %{conn: conn} do
conn = put(conn, ~p"/users/settings", %{"action" => "unknown"})
assert html_response(conn, 400) =~ "The settings form is invalid."
end
@tag :capture_log
test "updates the user email", %{conn: conn, user: user} do
assert_email_sent()
changed_email = unique_user_email()
conn =
put(conn, ~p"/users/settings", %{
"action" => "update_email",
"user" => %{"email" => unique_user_email()}
"user" => %{"email" => changed_email}
})
assert redirected_to(conn) == ~p"/users/settings"
@ -89,6 +101,11 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"A link to confirm your email"
assert_email_sent(fn email ->
email.text_body =~ "/users/settings/confirm-email#token=" and
not String.contains?(email.text_body, "/users/settings/confirm-email/")
end)
assert Accounts.get_user_by_email(user.email)
end
@ -103,9 +120,29 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert response =~ "Settings"
assert response =~ "must have the @ sign and no spaces"
end
test "rate limits repeated email-change instructions", %{conn: conn} do
previous = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"email_change_email" => %{limit: 1, window_seconds: 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end)
email = unique_user_email()
params = %{"action" => "update_email", "user" => %{"email" => email}}
assert redirected_to(put(conn, ~p"/users/settings", params)) == ~p"/users/settings"
limited_conn = put(conn, ~p"/users/settings", params)
response = html_response(limited_conn, 429)
assert response =~ "Too many email-change requests"
end
end
describe "GET /users/settings/confirm-email/:token" do
describe "POST /users/settings/confirm-email" do
setup %{user: user} do
email = unique_user_email()
@ -118,7 +155,12 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
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}")
page_conn = get(conn, ~p"/users/settings/confirm-email")
page_response = html_response(page_conn, 200)
assert page_response =~ ~s(id="email-change-fragment-form")
assert page_response =~ ~s(hidden)
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => token})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
@ -127,7 +169,7 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
refute Accounts.get_user_by_email(user.email)
assert Accounts.get_user_by_email(email)
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => token})
assert redirected_to(conn) == ~p"/users/settings"
@ -136,7 +178,31 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
end
test "does not update email with invalid token", %{conn: conn, user: user} do
conn = get(conn, ~p"/users/settings/confirm-email/oops")
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => "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 "does not update email without a token", %{conn: conn, user: user} do
conn = post(conn, ~p"/users/settings/confirm-email", %{})
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 "does not raise when the token has a forged nested type", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/settings/confirm-email", %{
"token" => %{"nested" => "value"}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
@ -147,8 +213,19 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
test "redirects if user is not logged in", %{token: token} do
conn = build_conn()
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => token})
assert redirected_to(conn) == ~p"/users/log-in"
end
@tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute)
test "accepts a valid token outside sudo mode", %{
conn: conn,
token: token,
email: email
} do
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => token})
assert redirected_to(conn) == ~p"/users/settings"
assert Accounts.get_user_by_email(email)
end
end
end

View File

@ -23,6 +23,32 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Створити термінову заявку"
end
test "profile social-link creation uses the shared action limiter", %{conn: conn} do
previous = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"add_social" => %{limit: 1, window_seconds: 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end)
{:ok, view, _html} = live(conn, ~p"/profile")
assert render_submit(view, "add-social", %{
"social_identity" => %{
"provider" => "telegram",
"profile_url" => "https://t.me/rate_limit_first"
}
}) =~ "Social link added as unverified."
assert render_submit(view, "add-social", %{
"social_identity" => %{
"provider" => "github",
"profile_url" => "https://github.com/rate-limit-second"
}
}) =~ "Too many actions in the configured time window."
end
test "invalid request and activity identifiers redirect without crashing", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/requests"}}} =
live(conn, "/requests/not-a-uuid")
@ -59,6 +85,38 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
refute render(view) =~ "Realtime medicine pickup"
end
test "request realtime updates keep a bounded window without skipping pagination" do
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Bounded realtime requester")
viewer = user_fixture(display_name: "Bounded realtime viewer")
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/requests")
requests =
for index <- 1..25 do
attrs =
category
|> request_attrs()
|> Map.put("title", "Bounded realtime request #{index}")
|> Map.put(
"expires_at",
DateTime.utc_now(:second) |> DateTime.add(3 * 60 * 60 + index, :second)
)
{:ok, request} = Help.create_request(Accounts.Scope.for_user(requester), attrs)
request
end
render(view)
first = List.first(requests)
overflow = List.last(requests)
assert has_element?(view, "#open-request-#{first.id}")
refute has_element?(view, "#open-request-#{overflow.id}")
view |> element("button", "Load more") |> render_click()
assert has_element?(view, "#open-request-#{overflow.id}")
end
test "activity index applies PubSub updates without a full page reload" do
Catalog.seed_defaults()
organizer = user_fixture(display_name: "Realtime organizer")
@ -89,6 +147,45 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert render(view) =~ "Realtime coffee meetup"
end
test "activity realtime updates keep a bounded window without skipping pagination" do
Catalog.seed_defaults()
organizer = user_fixture(display_name: "Bounded realtime organizer")
viewer = user_fixture(display_name: "Bounded realtime activity viewer")
category =
Catalog.list_categories(:activity)
|> Enum.find(&(&1.slug == "coffee-meetup"))
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/activities")
activities =
for index <- 1..25 do
attrs =
category
|> activity_attrs()
|> Map.put("title", "Bounded realtime activity #{index}")
|> Map.put(
"starts_at",
DateTime.utc_now(:second) |> DateTime.add(3 * 60 * 60 + index, :second)
)
{:ok, activity} =
Activities.create_activity(Accounts.Scope.for_user(organizer), attrs)
activity
end
render(view)
first = List.first(activities)
overflow = List.last(activities)
assert has_element?(view, "#open-activity-#{first.id}")
refute has_element?(view, "#open-activity-#{overflow.id}")
view |> element("button", "Load more") |> render_click()
assert has_element?(view, "#open-activity-#{overflow.id}")
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")
@ -259,6 +356,34 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "I will be there."
assert render(organizer_view) =~ "I will be there."
now = DateTime.utc_now(:second)
for index <- 1..51 do
body =
case index do
1 -> "oldest-realtime-activity-message"
51 -> "newest-realtime-activity-message"
_ -> "bounded activity message #{index}"
end
send(
organizer_view.pid,
{:activity_message,
%WhoNeedHelp.Activities.Message{
id: Ecto.UUID.generate(),
activity_id: activity.id,
sender_id: participant.id,
sender: participant,
body: body,
inserted_at: DateTime.add(now, index, :second)
}}
)
end
bounded_activity_html = render(organizer_view)
refute bounded_activity_html =~ "oldest-realtime-activity-message"
assert bounded_activity_html =~ "newest-realtime-activity-message"
stop_live_view(organizer_view)
stop_live_view(participant_view)
end
@ -294,6 +419,35 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert_push_event(helper_view, "reset-message-form", %{id: "message-form"})
assert render(requester_view) =~ "I am on my way to the pharmacy."
now = DateTime.utc_now(:second)
assignment_id = Repo.get_by!(WhoNeedHelp.Help.Assignment, request_id: request.id).id
for index <- 1..51 do
body =
case index do
1 -> "oldest-realtime-help-message"
51 -> "newest-realtime-help-message"
_ -> "bounded help message #{index}"
end
send(
requester_view.pid,
{:new_message,
%WhoNeedHelp.Messaging.Message{
id: Ecto.UUID.generate(),
assignment_id: assignment_id,
sender_id: helper.id,
sender: helper,
body: body,
inserted_at: DateTime.add(now, index, :second)
}}
)
end
bounded_help_html = render(requester_view)
refute bounded_help_html =~ "oldest-realtime-help-message"
assert bounded_help_html =~ "newest-realtime-help-message"
send(
requester_view.pid,
{:position_updated, helper.id,
@ -431,6 +585,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
}
end
defp activity_attrs(category) do
%{
"title" => "Public coffee meetup",
"description" => "Meet at a public café for a short conversation.",
"structured_data" => %{"setting" => "cafe"},
"location_label" => "Central square",
"latitude" => "50.4501",
"longitude" => "30.5234",
"location_visibility" => "approximate_public",
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
"capacity" => 3,
"category_id" => category.id,
"safety_confirmed" => true
}
end
defp stop_live_view(%Phoenix.LiveViewTest.View{
proxy: {_ref, _topic, proxy_pid}
}) do