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/assets/
/priv/static/cache_manifest.json /priv/static/cache_manifest.json
/android/.gradle/ /android/.gradle/
/android/.kotlin/
/android/.idea/
/android/.cxx/
/android/app/build/ /android/app/build/
/android/build/ /android/build/
/android/dist/ /android/dist/
/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/ /.tools/
/.playwright-cli/ /.playwright-cli/
/output/ /output/

View File

@ -29,8 +29,8 @@ WNH_BASE_URL=
# production capacity recommendations. # production capacity recommendations.
WNH_TRACKING_MIN_TIME_MS=5000 WNH_TRACKING_MIN_TIME_MS=5000
WNH_TRACKING_HTTP_TIMEOUT_MS=15000 WNH_TRACKING_HTTP_TIMEOUT_MS=15000
WNH_ANDROID_TEST_API_MATRIX=30 34 37.0 WNH_ANDROID_TEST_API_MATRIX="24 30 34 37.0"
WNH_ANDROID_TEST_DATA_PARTITION_SIZE=4G WNH_ANDROID_TEST_DATA_PARTITION_SIZE=1G
# Public raster tile template used by MapLibre. Use a provider whose policy and # Public raster tile template used by MapLibre. Use a provider whose policy and
# capacity match the deployment before a public launch. # capacity match the deployment before a public launch.
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png

6
.gitignore vendored
View File

@ -67,3 +67,9 @@ __pycache__/
*.aab *.aab
*.jks *.jks
*.keystore *.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, The Android device suite builds dedicated debug and instrumentation APKs,
boots a fresh emulator in an isolated container without external networking, 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 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 ```bash
./scripts/android-instrumentation-test.sh ./scripts/android-instrumentation-test.sh

View File

@ -1,6 +1,17 @@
.gradle .gradle
.kotlin
.idea
.cxx
app/build app/build
build build
dist dist
dist-* dist-*
local.properties 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 while the Activity is minimized without requesting Android's
background-location permission. 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 ## Verified build configuration
- Android Gradle Plugin 9.3.0 - Android Gradle Plugin 9.3.0
- Gradle 9.6.1 - Gradle 9.6.1
- Android SDK Command-line Tools 22.0 - Android SDK Command-line Tools 22.0
- Android CLI 1.0.15857036 (embedded in the locked Command-line Tools archive) - Android CLI 1.0.15857036 (embedded in the locked Command-line Tools archive)
- AndroidX WebKit 1.16.0
- compileSdk / targetSdk 37 - compileSdk / targetSdk 37
- Build Tools 37.0.0 - Build Tools 37.0.0
- Java source and bytecode level 17 - Java source and bytecode level 17
@ -88,7 +93,7 @@ report.
## Automated device tests ## 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: repository root:
```sh ```sh

View File

@ -20,6 +20,9 @@ fun manifestOrigin(value: String): URI? =
(uri.scheme == "http" || uri.scheme == "https") && !uri.host.isNullOrBlank() (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 debugManifestOrigin = manifestOrigin(debugBaseUrl.get())
val releaseManifestOrigin = manifestOrigin(releaseBaseUrl.get()) val releaseManifestOrigin = manifestOrigin(releaseBaseUrl.get())
@ -119,6 +122,7 @@ tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.
uri.scheme != "https" || uri.scheme != "https" ||
uri.host.isNullOrBlank() || uri.host.isNullOrBlank() ||
uri.userInfo != null || uri.userInfo != null ||
!isOriginPath(uri) ||
uri.query != null || uri.query != null ||
uri.fragment != null uri.fragment != null
) { ) {
@ -142,6 +146,7 @@ tasks.matching { it.name == "preDebugBuild" }.configureEach {
(uri.scheme != "http" && uri.scheme != "https") || (uri.scheme != "http" && uri.scheme != "https") ||
uri.host.isNullOrBlank() || uri.host.isNullOrBlank() ||
uri.userInfo != null || uri.userInfo != null ||
!isOriginPath(uri) ||
uri.query != null || uri.query != null ||
uri.fragment != null uri.fragment != null
) { ) {
@ -177,6 +182,7 @@ tasks.withType<JavaCompile>().configureEach {
dependencies { dependencies {
implementation("androidx.activity:activity:1.13.0") implementation("androidx.activity:activity:1.13.0")
implementation("androidx.webkit:webkit:1.16.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:core:1.7.0") androidTestImplementation("androidx.test:core:1.7.0")
androidTestImplementation("androidx.test:runner: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 static org.junit.Assert.assertTrue;
import android.Manifest; import android.Manifest;
import android.app.Notification;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.SystemClock;
import android.service.notification.StatusBarNotification;
import androidx.test.core.app.ActivityScenario; import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
@ -70,16 +74,20 @@ public final class AndroidClientInstrumentedTest {
assertFalse(hasLocationPermission()); assertFalse(hasLocationPermission());
try (ActivityScenario<MainActivity> scenario = launch("/permission-boundary")) { try (ActivityScenario<MainActivity> scenario = launch("/permission-boundary")) {
scenario.onActivity(activity -> scenario.onActivity(activity -> {
activity.startForegroundService( Intent intent = TrackingService.startIntent(
TrackingService.startIntent( activity,
activity, UUID.randomUUID().toString(),
UUID.randomUUID().toString(), "csrf-without-permission",
"csrf-without-permission", "_session=without-permission"
"_session=without-permission" );
)
) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
); activity.startForegroundService(intent);
} else {
activity.startService(intent);
}
});
waitForServiceState(false); waitForServiceState(false);
assertFalse( assertFalse(
@ -99,17 +107,45 @@ public final class AndroidClientInstrumentedTest {
.withElement(findElement(Locator.ID, "location")) .withElement(findElement(Locator.ID, "location"))
.perform(webClick()); .perform(webClick());
UiObject2 allow = device.wait( UiObject2 allow = null;
Until.findObject(
By.res(
LOCATION_PERMISSION_CONTROLLER,
"permission_allow_foreground_only_button"
)
),
UI_TIMEOUT_MS
);
if (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,
"permission_allow_foreground_only_button"
)
),
UI_TIMEOUT_MS
);
}
if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
allow = device.wait( allow = device.wait(
Until.findObject(By.textContains("While using the app")), Until.findObject(By.textContains("While using the app")),
UI_TIMEOUT_MS UI_TIMEOUT_MS
@ -328,16 +364,20 @@ public final class AndroidClientInstrumentedTest {
ActivityScenario<MainActivity> scenario, ActivityScenario<MainActivity> scenario,
String assignmentId String assignmentId
) { ) {
scenario.onActivity(activity -> scenario.onActivity(activity -> {
activity.startForegroundService( Intent intent = TrackingService.startIntent(
TrackingService.startIntent( activity,
activity, assignmentId,
assignmentId, "instrumentation-csrf",
"instrumentation-csrf", "_session=instrumentation"
"_session=instrumentation" );
)
) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
); activity.startForegroundService(intent);
} else {
activity.startService(intent);
}
});
} }
private void openNotificationAndClickStop() { private void openNotificationAndClickStop() {
@ -347,14 +387,59 @@ public final class AndroidClientInstrumentedTest {
UI_TIMEOUT_MS UI_TIMEOUT_MS
); );
assertNotNull("Foreground tracking notification was not visible", active); assertNotNull("Foreground tracking notification was not visible", active);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
sendStopActionFromActiveNotification();
return;
}
UiObject2 stop = device.wait( UiObject2 stop = device.wait(
Until.findObject(By.text(context.getString(R.string.tracking_stop_action))), Until.findObject(By.text(context.getString(R.string.tracking_stop_action))),
UI_TIMEOUT_MS UI_TIMEOUT_MS
); );
assertNotNull("Foreground tracking notification had no Stop action", stop); assertNotNull("Foreground tracking notification had no Stop action", stop);
stop.click(); 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() { private boolean hasLocationPermission() {
return context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) return context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
== android.content.pm.PackageManager.PERMISSION_GRANTED == android.content.pm.PackageManager.PERMISSION_GRANTED

View File

@ -39,15 +39,19 @@ public final class TrackingProcessDeathProbeTest {
); );
} }
context.startForegroundService( Intent intent = TrackingService.startIntent(
TrackingService.startIntent( context,
context, UUID.randomUUID().toString(),
UUID.randomUUID().toString(), "process-death-csrf",
"process-death-csrf", "_session=process-death"
"_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 // The host harness kills this exact process after observing the active
// service and notification. Reaching the deadline means the harness did // service and notification. Reaching the deadline means the harness did
// not perform the required process-death probe. // not perform the required process-death probe.

View File

@ -15,7 +15,6 @@ import android.util.Log;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.webkit.CookieManager; import android.webkit.CookieManager;
import android.webkit.GeolocationPermissions; import android.webkit.GeolocationPermissions;
import android.webkit.JavascriptInterface;
import android.webkit.SslErrorHandler; import android.webkit.SslErrorHandler;
import android.webkit.WebChromeClient; import android.webkit.WebChromeClient;
import android.webkit.WebResourceError; import android.webkit.WebResourceError;
@ -30,10 +29,17 @@ import androidx.activity.ComponentActivity;
import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts; 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.ArrayList;
import java.util.Collections;
import java.util.UUID; import java.util.UUID;
import org.json.JSONException;
import org.json.JSONObject;
public final class MainActivity extends ComponentActivity { public final class MainActivity extends ComponentActivity {
private static final String LOG_TAG = "WhoNeedHelpWebView"; private static final String LOG_TAG = "WhoNeedHelpWebView";
private WebView webView; private WebView webView;
@ -106,7 +112,7 @@ public final class MainActivity extends ComponentActivity {
cookieManager.setAcceptCookie(true); cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, false); cookieManager.setAcceptThirdPartyCookies(webView, false);
webView.addJavascriptInterface(new NativeTrackingBridge(), "WhoNeedHelpAndroid"); configureNativeTrackingBridge();
webView.setWebViewClient(new TrustedWebViewClient()); webView.setWebViewClient(new TrustedWebViewClient());
webView.setWebChromeClient(new LocationWebChromeClient()); webView.setWebChromeClient(new LocationWebChromeClient());
getOnBackPressedDispatcher().addCallback( getOnBackPressedDispatcher().addCallback(
@ -192,7 +198,6 @@ public final class MainActivity extends ComponentActivity {
if (webView != null) { if (webView != null) {
webView.stopLoading(); webView.stopLoading();
webView.removeJavascriptInterface("WhoNeedHelpAndroid");
webView.setWebChromeClient(null); webView.setWebChromeClient(null);
webView.setWebViewClient(null); webView.setWebViewClient(null);
webView.destroy(); webView.destroy();
@ -275,6 +280,60 @@ public final class MainActivity extends ComponentActivity {
nativeTrackingPermissionLauncher.launch(permissions.toArray(new String[0])); 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() { private void startPendingNativeTracking() {
PendingNativeTracking pending = pendingNativeTracking; PendingNativeTracking pending = pendingNativeTracking;
pendingNativeTracking = null; 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 static final class PendingNativeTracking {
private final String assignmentId; private final String assignmentId;
private final String csrfToken; private final String csrfToken;

View File

@ -30,6 +30,7 @@ final class TrustedOrigin {
uri.getHost() == null || uri.getHost() == null ||
uri.getHost().trim().isEmpty() || uri.getHost().trim().isEmpty() ||
uri.getUserInfo() != null || uri.getUserInfo() != null ||
!pathIsOrigin(uri.getPath()) ||
uri.getQuery() != null || uri.getQuery() != null ||
uri.getFragment() != null uri.getFragment() != null
) { ) {
@ -43,6 +44,22 @@ final class TrustedOrigin {
return base.toString(); 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) { boolean matches(String value) {
try { try {
URI candidate = new URI(value); URI candidate = new URI(value);

View File

@ -1,6 +1,7 @@
package org.whoneedhelp.mobile; package org.whoneedhelp.mobile;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; 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("https://help.example.evil.test/"));
assertFalse(origin.matches("http://help.example/")); assertFalse(origin.matches("http://help.example/"));
assertFalse(origin.matches("https://help.example:444/")); assertFalse(origin.matches("https://help.example:444/"));
assertEquals("https://help.example", origin.originRule());
} }
@Test @Test
@ -25,6 +27,7 @@ public final class TrustedOriginTest {
assertTrue(origin.matches("http://10.0.2.2:4010/requests")); assertTrue(origin.matches("http://10.0.2.2:4010/requests"));
assertTrue(origin.matchesOrigin("http://10.0.2.2:4010/")); assertTrue(origin.matchesOrigin("http://10.0.2.2:4010/"));
assertFalse(origin.matchesOrigin("http://10.0.2.2:4010/requests")); assertFalse(origin.matchesOrigin("http://10.0.2.2:4010/requests"));
assertEquals("http://10.0.2.2:4010", origin.originRule());
} }
@Test @Test
@ -41,5 +44,9 @@ public final class TrustedOriginTest {
IllegalArgumentException.class, IllegalArgumentException.class,
() -> TrustedOrigin.parse("https://help.example?redirect=evil", false) () -> 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 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, { const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: { params: {
_csrf_token: csrfToken, _csrf_token: csrfToken,
client_type: window.WhoNeedHelpAndroid ? "android" : "browser" client_type: typeof window.WhoNeedHelpAndroid?.postMessage === "function" ? "android" : "browser"
}, },
hooks: {...colocatedHooks, ...Hooks}, hooks: {...colocatedHooks, ...Hooks},
}) })
@ -74,10 +109,14 @@ window.addEventListener("phx:reset-message-form", ({detail}) => {
if (form instanceof HTMLFormElement) form.reset() if (form instanceof HTMLFormElement) form.reset()
}) })
window.addEventListener("phx:native-tracking-start", ({detail}) => { 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.addEventListener("phx:native-tracking-stop", () => {
window.WhoNeedHelpAndroid?.stopTracking() window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({action: "stop"}))
}) })
// connect if there are any LiveViews on the page // connect if there are any LiveViews on the page

View File

@ -1,5 +1,7 @@
import maplibregl from "maplibre-gl" import maplibregl from "maplibre-gl"
const trackingMinTimeMs = 5000
const defaultStyle = { const defaultStyle = {
version: 8, version: 8,
sources: { sources: {
@ -134,8 +136,43 @@ export const Hooks = {
LiveTracking: { LiveTracking: {
mounted() { mounted() {
this.destroying = false 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.nativeError = () => {
this.pushEvent("location-error", {}) this.pushEvent("location-error", {})
this.pushEvent("stop-tracking", {}) this.pushEvent("stop-tracking", {})
@ -145,7 +182,7 @@ export const Hooks = {
} }
this.watchId = navigator.geolocation.watchPosition( this.watchId = navigator.geolocation.watchPosition(
position => this.pushEvent("location-update", { position => this.queueLocation({
latitude: position.coords.latitude, latitude: position.coords.latitude,
longitude: position.coords.longitude, longitude: position.coords.longitude,
accuracy_meters: position.coords.accuracy accuracy_meters: position.coords.accuracy
@ -158,6 +195,13 @@ export const Hooks = {
}, },
destroyed() { destroyed() {
this.destroying = true this.destroying = true
this.pendingLocation = null
if (this.locationTimer !== undefined) {
window.clearTimeout(this.locationTimer)
this.locationTimer = undefined
}
if (this.nativeError) { if (this.nativeError) {
window.removeEventListener("wnh:native-tracking-error", 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_AUTHORIZE_URL: http://external-mock:8080/oauth/authorize
GITHUB_OAUTH_TOKEN_URL: http://external-mock:8080/oauth/token GITHUB_OAUTH_TOKEN_URL: http://external-mock:8080/oauth/token
GITHUB_OAUTH_USER_URL: http://external-mock:8080/oauth/user 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_CONNECT_TIMEOUT_MS: "100"
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: "100" GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: "100"
SMTP_RELAY: external-mock SMTP_RELAY: external-mock

View File

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

View File

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

View File

@ -8,6 +8,42 @@ app_role =
other -> raise "APP_ROLE must be web, worker, or migrate; got #{inspect(other)}" other -> raise "APP_ROLE must be web, worker, or migrate; got #{inspect(other)}"
end 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, config :who_need_help,
app_role: app_role, app_role: app_role,
codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"), codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"),
@ -16,12 +52,7 @@ config :who_need_help,
"MAP_TILE_URL", "MAP_TILE_URL",
Application.fetch_env!(:who_need_help, :map_tile_url) Application.fetch_env!(:who_need_help, :map_tile_url)
), ),
rate_limit_policies: rate_limit_policies: rate_limit_policies
(case System.get_env("RATE_LIMIT_POLICIES_JSON") do
nil -> %{}
"" -> %{}
json -> Jason.decode!(json)
end)
optional_positive_integer = fn name -> optional_positive_integer = fn name ->
case System.get_env(name) do case System.get_env(name) do
@ -74,6 +105,13 @@ required_non_negative_integer = fn name ->
end end
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 -> oauth_endpoint = fn name, default ->
value = value =
case System.get_env(name) do case System.get_env(name) do
@ -83,11 +121,15 @@ oauth_endpoint = fn name, default ->
case URI.parse(value) do case URI.parse(value) do
%URI{scheme: scheme, host: host} %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 value
_other -> _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
end end
@ -155,11 +197,15 @@ push_configuration =
bearer_token != "" -> bearer_token != "" ->
case URI.parse(endpoint) do case URI.parse(endpoint) do
%URI{scheme: scheme, host: host} %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 :ok
_other -> _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 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 foreground location service. Its debug origin is supplied at build time from
the repository's ignored `.env`; release builds require an explicit HTTPS the repository's ignored `.env`; release builds require an explicit HTTPS
origin. Authentication cookies, LiveView WebSockets, MapLibre, and private chat origin. Authentication cookies, LiveView WebSockets, MapLibre, and private chat
use the same Phoenix application as the browser. A JavaScript bridge starts the use the same Phoenix application as the browser. An origin-restricted
native service only from the visible Activity; the service posts the current `WebViewCompat` message listener accepts tracking commands only from the trusted
point through CSRF-protected, participant-authorized same-origin routes and main frame and starts the native service only from the visible Activity; the
shows a persistent notification with Stop. Production signing and distribution service posts the current point through CSRF-protected,
are separate operational work and are not represented as complete. 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 ## 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 ignored mode-`0600` environment file, builds the production release plus a
non-root standard-library Python protocol mock, and then verifies: 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 1. GitHub-compatible OAuth authorization, PKCE S256, token exchange, normalized
user lookup, state mismatch, provider rejection, one-time-code replay, user lookup, state mismatch, provider rejection, one-time-code replay,
a fresh flow after a temporary token error, and a token timeout; 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( const emailChangeLink = await waitForApplicationEmailLink(
request, request,
changedEmail, changedEmail,
"/users/settings/confirm-email/", "/users/settings/confirm-email",
); );
recoverableOneTimeNavigations.add(emailChangeLink); recoverableOneTimeNavigations.add(emailChangeLink);
try { try {
await user.page.goto(emailChangeLink); 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(); await expect(user.page.getByText("Email changed successfully.")).toBeVisible();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);

View File

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

View File

@ -151,7 +151,7 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
"path" => "/requests/#{fixture.request.id}" "path" => "/requests/#{fixture.request.id}"
}, },
"assignment" => %{"id" => fixture.assignment.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!() 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 unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
manifest["database"] == context.database and valid_users? and valid_request? and manifest["database"] == context.database and valid_users? and valid_request? and
valid_assignment? and is_binary(manifest["helper_login_path"]) 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") Mix.raise("Android staging E2E manifest does not match the requested run and database")
end end
end end

View File

@ -61,7 +61,7 @@ defmodule WhoNeedHelp.Accounts do
""" """
def get_user_by_email(email) when is_binary(email) 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 end
@doc """ @doc """
@ -78,7 +78,7 @@ defmodule WhoNeedHelp.Accounts do
""" """
def get_user_by_email_and_password(email, password) def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do 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, if User.valid_password?(user, password) and user.moderation_status != :suspended,
do: user do: user
@ -381,9 +381,9 @@ defmodule WhoNeedHelp.Accounts do
Repo.transact(fn -> Repo.transact(fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), 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})), {: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 Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
{:ok, user} {:ok, user}
else else
@ -480,34 +480,46 @@ defmodule WhoNeedHelp.Accounts do
`mix help phx.gen.auth`. `mix help phx.gen.auth`.
""" """
def login_user_by_magic_link(token) do def login_user_by_magic_link(token) do
{:ok, query} = UserToken.verify_magic_link_token_query(token) 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} ->
with {:ok, _token} <- Repo.delete(token) do
{:ok, {:rejected, :not_found}}
end
case Repo.one(query) do # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%User{moderation_status: :suspended}, token} -> {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
Repo.delete!(token) raise """
{:error, :not_found} magic link log in is not allowed for unconfirmed users with a password set!
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password This cannot happen with the default implementation, which indicates that you
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) -> might have adapted the code to a different use case. Please make sure to read the
raise """ "Mixing magic link and password registration" section of `mix help phx.gen.auth`.
magic link log in is not allowed for unconfirmed users with a password set! """
This cannot happen with the default implementation, which indicates that you {%User{confirmed_at: nil} = user, _token} ->
might have adapted the code to a different use case. Please make sure to read the user
"Mixing magic link and password registration" section of `mix help phx.gen.auth`. |> User.confirm_changeset()
""" |> update_user_and_delete_all_tokens_in_transaction()
{%User{confirmed_at: nil} = user, _token} -> {user, token} ->
user with {:ok, _token} <- Repo.delete(token) do
|> User.confirm_changeset() {:ok, {user, []}}
|> update_user_and_delete_all_tokens() end
{user, token} -> nil ->
Repo.delete!(token) {:error, :not_found}
{:ok, {user, []}} end
end)
nil -> case result do
{:error, :not_found} {:ok, {:rejected, reason}} -> {:error, reason}
other -> other
end
else
_invalid_token -> {:error, :not_found}
end end
end end
@ -516,7 +528,7 @@ defmodule WhoNeedHelp.Accounts do
## Examples ## 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: ...}} {:ok, %{to: ..., body: ...}}
""" """
@ -546,6 +558,12 @@ defmodule WhoNeedHelp.Accounts do
:ok :ok
end end
def delete_expired_user_tokens(now \\ DateTime.utc_now(:second)) do
now
|> UserToken.expired_tokens_query()
|> Repo.delete_all()
end
## Token helper ## Token helper
defp before_moderation_user(query, nil), do: query defp before_moderation_user(query, nil), do: query
@ -567,14 +585,16 @@ defmodule WhoNeedHelp.Accounts do
end end
defp update_user_and_delete_all_tokens(changeset) do 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)
with {:ok, user} <- Repo.update(changeset) do end
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id))) 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)
{:ok, {user, tokens_to_expire}} Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
end
end) {:ok, {user, tokens_to_expire}}
end
end end
end end

View File

@ -21,10 +21,10 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity do
identity identity
|> cast(attrs, [:provider, :profile_url, :handle]) |> cast(attrs, [:provider, :profile_url, :handle])
|> validate_required([:provider, :profile_url, :user_id]) |> validate_required([:provider, :profile_url, :user_id])
|> validate_format(:profile_url, ~r/^https?:\/\/[^\s]+$/i, |> validate_format(:profile_url, ~r/^https:\/\/[^\s]+$/i,
message: "must be a complete http(s) URL" message: "must be a complete HTTPS URL"
) )
|> validate_length(:profile_url, max: 500) |> validate_length(:profile_url, max: 255)
|> validate_length(:handle, max: 100) |> validate_length(:handle, max: 100)
|> unique_constraint([:provider, :provider_uid]) |> unique_constraint([:provider, :provider_uid])
end end
@ -51,7 +51,7 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity do
message: "must be a GitHub profile URL" message: "must be a GitHub profile URL"
) )
|> validate_length(:provider_uid, max: 255) |> validate_length(:provider_uid, max: 255)
|> validate_length(:profile_url, max: 500) |> validate_length(:profile_url, max: 255)
|> validate_length(:handle, max: 100) |> validate_length(:handle, max: 100)
|> unique_constraint([:provider, :provider_uid]) |> unique_constraint([:provider, :provider_uid])
end end

View File

@ -89,6 +89,7 @@ defmodule WhoNeedHelp.Accounts.User do
|> validate_length(:display_name, min: 2, max: 80) |> validate_length(:display_name, min: 2, max: 80)
|> validate_length(:bio, max: 600) |> validate_length(:bio, max: 600)
|> validate_inclusion(:locale, ~w(en uk ru)) |> validate_inclusion(:locale, ~w(en uk ru))
|> validate_length(:tip_url, max: 255)
|> validate_url(:tip_url) |> validate_url(:tip_url)
end end
@ -108,12 +109,12 @@ defmodule WhoNeedHelp.Accounts.User do
defp validate_url(changeset, field) do defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, value -> validate_change(changeset, field, fn ^field, value ->
case URI.parse(value) do case URI.parse(value) do
%URI{scheme: scheme, host: host} %URI{scheme: "https", host: host}
when scheme in ["https", "http"] and is_binary(host) and 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) end)
end end

View File

@ -153,6 +153,18 @@ defmodule WhoNeedHelp.Accounts.UserToken do
end end
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 defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context] from UserToken, where: [token: ^token, context: ^context]
end end

View File

@ -19,13 +19,15 @@ defmodule WhoNeedHelp.Activities do
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic) 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), def subscribe_activity(id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activity:#{id}") do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activity:#{id}")
def notify_activity_updated(id) do def notify_activity_updated(id) do
activity = load_activity(id) broadcast({:activity_updated, load_activity_summary(id)})
broadcast({:activity_updated, activity}) broadcast_activity(id, {:activity_updated, load_activity(id)})
broadcast_activity(id, {:activity_updated, activity})
:ok :ok
end end
@ -37,7 +39,7 @@ defmodule WhoNeedHelp.Activities do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
public_creator = Accounts.public_user_query(social_identities: true) public_creator = Accounts.public_user_query()
Activity Activity
|> where( |> where(
@ -231,39 +233,41 @@ defmodule WhoNeedHelp.Activities do
end end
with {:ok, activity} <- result do with {:ok, activity} <- result do
activity = load_activity(activity.id) broadcast({:activity_created, load_activity_summary(activity.id)})
broadcast({:activity_created, activity}) {:ok, load_activity(activity.id)}
{:ok, activity}
end end
end end
def request_to_join(%Scope{user: user} = scope, activity_id) do def request_to_join(%Scope{user: user} = scope, activity_id) do
result = 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 -> Repo.transact(fn ->
activity = locked_activity(activity_id) with %Activity{} = activity <- locked_activity(activity_id) do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
:ok = Trust.lock_user_pair(activity.creator_id, user.id) cond do
activity.creator_id == user.id ->
{:error, :organizer_already_joined}
cond do activity.status != :open or not is_nil(activity.hidden_at) ->
activity.creator_id == user.id -> {:error, :not_open}
{:error, :organizer_already_joined}
activity.status != :open or not is_nil(activity.hidden_at) -> DateTime.compare(activity.join_deadline, now) != :gt ->
{:error, :not_open} {:error, :join_closed}
DateTime.compare(activity.join_deadline, now) != :gt -> Trust.blocked_between?(activity.creator_id, user.id) ->
{:error, :join_closed} {:error, :blocked}
Trust.blocked_between?(activity.creator_id, user.id) -> approved_count(activity.id) >= activity.capacity ->
{:error, :blocked} {:error, :capacity_reached}
approved_count(activity.id) >= activity.capacity -> true ->
{:error, :capacity_reached} upsert_join_request(activity.id, user.id, now)
end
true -> else
upsert_join_request(activity.id, user.id, now) nil -> {:error, :not_found}
end end
end) end)
end end
@ -275,44 +279,47 @@ defmodule WhoNeedHelp.Activities do
result = result =
with {:ok, participant_id} <- cast_id(participant_id) do with {:ok, participant_id} <- cast_id(participant_id) do
Repo.transact(fn -> Repo.transact(fn ->
participant = locked_participant(participant_id) with %Participant{} = participant <- locked_participant(participant_id),
activity = locked_activity(participant.activity_id) %Activity{} = activity <- locked_activity(participant.activity_id) do
:ok = Trust.lock_user_pair(activity.creator_id, participant.user_id) :ok = Trust.lock_user_pair(activity.creator_id, participant.user_id)
cond do cond do
activity.creator_id != organizer.id -> activity.creator_id != organizer.id ->
{:error, :forbidden} {:error, :forbidden}
participant.status != :requested -> participant.status != :requested ->
{:error, :invalid_transition} {:error, :invalid_transition}
activity.status != :open -> activity.status != :open ->
{:error, :not_open} {:error, :not_open}
Trust.blocked_between?(activity.creator_id, participant.user_id) -> Trust.blocked_between?(activity.creator_id, participant.user_id) ->
{:error, :blocked} {:error, :blocked}
approved_count(activity.id) >= activity.capacity -> approved_count(activity.id) >= activity.capacity ->
{:error, :capacity_reached} {:error, :capacity_reached}
true -> true ->
with {:ok, participant} <- with {:ok, participant} <-
participant participant
|> Participant.changeset(%{ |> Participant.changeset(%{
status: :approved, status: :approved,
reviewed_at: DateTime.utc_now(:second) reviewed_at: DateTime.utc_now(:second)
}) })
|> Repo.update(), |> Repo.update(),
{:ok, _audit} <- {:ok, _audit} <-
Trust.audit( Trust.audit(
organizer.id, organizer.id,
"activity.participant_approved", "activity.participant_approved",
"activity_participant", "activity_participant",
participant.id, participant.id,
%{"activity_id" => activity.id, "user_id" => participant.user_id} %{"activity_id" => activity.id, "user_id" => participant.user_id}
) do ) do
{:ok, participant} {:ok, participant}
end end
end
else
nil -> {:error, :not_found}
end end
end) end)
end end
@ -328,40 +335,42 @@ defmodule WhoNeedHelp.Activities do
def leave_activity(%Scope{user: user}, activity_id) do def leave_activity(%Scope{user: user}, activity_id) do
result = result =
Repo.transact(fn -> with {:ok, activity_id} <- cast_id(activity_id) do
participant = Repo.transact(fn ->
Participant participant =
|> where( Participant
[participant], |> where(
participant.activity_id == ^activity_id and participant.user_id == ^user.id [participant],
) participant.activity_id == ^activity_id and participant.user_id == ^user.id
|> lock("FOR UPDATE") )
|> Repo.one() |> lock("FOR UPDATE")
|> Repo.one()
cond do cond do
is_nil(participant) -> is_nil(participant) ->
{:error, :not_found} {:error, :not_found}
participant.role == :organizer -> participant.role == :organizer ->
{:error, :organizer_cannot_leave} {:error, :organizer_cannot_leave}
participant.status not in [:requested, :approved] -> participant.status not in [:requested, :approved] ->
{:error, :invalid_transition} {:error, :invalid_transition}
true -> true ->
with {:ok, participant} <- with {:ok, participant} <-
participant participant
|> Participant.changeset(%{ |> Participant.changeset(%{
status: :left, status: :left,
left_at: DateTime.utc_now(:second) left_at: DateTime.utc_now(:second)
}) })
|> Repo.update(), |> Repo.update(),
{:ok, _audit} <- {:ok, _audit} <-
Trust.audit(user.id, "activity.left", "activity", activity_id) do Trust.audit(user.id, "activity.left", "activity", activity_id) do
{:ok, participant} {:ok, participant}
end end
end end
end) end)
end
after_participant_change(result, activity_id, :participant_left) after_participant_change(result, activity_id, :participant_left)
end end
@ -376,29 +385,33 @@ defmodule WhoNeedHelp.Activities do
def send_message(%Scope{user: user} = scope, activity_id, attrs) do def send_message(%Scope{user: user} = scope, activity_id, attrs) do
result = 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 -> 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) :ok = Trust.lock_user_pair(activity.creator_id, user.id)
cond do cond do
activity.status != :open or not is_nil(activity.hidden_at) -> activity.status != :open or not is_nil(activity.hidden_at) ->
{:error, :not_open} {:error, :not_open}
Trust.blocked_between?(activity.creator_id, user.id) -> Trust.blocked_between?(activity.creator_id, user.id) ->
{:error, :blocked} {:error, :blocked}
not approved_participant_id?(activity.id, user.id) -> not approved_participant_id?(activity.id, user.id) ->
{:error, :forbidden} {:error, :forbidden}
true -> true ->
%Message{} %Message{}
|> Message.changeset(%{ |> Message.changeset(%{
body: attrs["body"] || attrs[:body], body: attrs["body"] || attrs[:body],
activity_id: activity.id, activity_id: activity.id,
sender_id: user.id sender_id: user.id
}) })
|> Repo.insert() |> Repo.insert()
end
else
nil -> {:error, :not_found}
end end
end) end)
end end
@ -514,34 +527,36 @@ defmodule WhoNeedHelp.Activities do
defp transition_participant(organizer_id, participant_id, status) do defp transition_participant(organizer_id, participant_id, status) do
result = result =
Repo.transact(fn -> Repo.transact(fn ->
participant = locked_participant(participant_id) with %Participant{} = participant <- locked_participant(participant_id),
activity = locked_activity(participant.activity_id) %Activity{} = activity <- locked_activity(participant.activity_id) do
cond do
activity.creator_id != organizer_id ->
{:error, :forbidden}
cond do participant.status != :requested ->
activity.creator_id != organizer_id -> {:error, :invalid_transition}
{:error, :forbidden}
participant.status != :requested -> true ->
{:error, :invalid_transition} with {:ok, participant} <-
participant
true -> |> Participant.changeset(%{
with {:ok, participant} <- status: status,
participant reviewed_at: DateTime.utc_now(:second)
|> Participant.changeset(%{ })
status: status, |> Repo.update(),
reviewed_at: DateTime.utc_now(:second) {:ok, _audit} <-
}) Trust.audit(
|> Repo.update(), organizer_id,
{:ok, _audit} <- "activity.participant_#{status}",
Trust.audit( "activity_participant",
organizer_id, participant.id,
"activity.participant_#{status}", %{"activity_id" => activity.id, "user_id" => participant.user_id}
"activity_participant", ) do
participant.id, {:ok, participant}
%{"activity_id" => activity.id, "user_id" => participant.user_id} end
) do end
{:ok, participant} else
end nil -> {:error, :not_found}
end end
end) end)
@ -550,37 +565,42 @@ defmodule WhoNeedHelp.Activities do
defp transition_activity(user_id, activity_id, target_status) do defp transition_activity(user_id, activity_id, target_status) do
result = result =
Repo.transact(fn -> with {:ok, activity_id} <- cast_id(activity_id) do
activity = locked_activity(activity_id) Repo.transact(fn ->
with %Activity{} = activity <- locked_activity(activity_id) do
cond do
activity.creator_id != user_id ->
{:error, :forbidden}
cond do activity.status != :open ->
activity.creator_id != user_id -> {:error, :invalid_transition}
{:error, :forbidden}
activity.status != :open -> true ->
{:error, :invalid_transition} timestamp = DateTime.utc_now(:second)
true -> attrs =
timestamp = DateTime.utc_now(:second) case target_status do
:cancelled -> %{status: :cancelled, cancelled_at: timestamp}
:completed -> %{status: :completed, completed_at: timestamp}
end
attrs = with {:ok, activity} <-
case target_status do activity |> Ecto.Changeset.change(attrs) |> Repo.update(),
:cancelled -> %{status: :cancelled, cancelled_at: timestamp} {:ok, _audit} <-
:completed -> %{status: :completed, completed_at: timestamp} Trust.audit(
end user_id,
"activity.#{target_status}",
with {:ok, activity} <- activity |> Ecto.Changeset.change(attrs) |> Repo.update(), "activity",
{:ok, _audit} <- activity.id
Trust.audit( ) do
user_id, {:ok, activity}
"activity.#{target_status}", end
"activity",
activity.id
) do
{:ok, activity}
end end
end else
end) nil -> {:error, :not_found}
end
end)
end
with {:ok, activity} <- result do with {:ok, activity} <- result do
activity = load_activity(activity.id) activity = load_activity(activity.id)
@ -592,9 +612,10 @@ defmodule WhoNeedHelp.Activities do
defp after_participant_change({:ok, participant}, activity_id, event) defp after_participant_change({:ok, participant}, activity_id, event)
when is_binary(activity_id) do when is_binary(activity_id) do
activity = load_activity(activity_id) summary = load_activity_summary(activity_id)
broadcast({:activity_updated, activity}) broadcast({:activity_updated, summary})
broadcast_activity(activity.id, {event, participant}) broadcast_user(participant.user_id, {:my_activity_updated, summary, member?(participant)})
broadcast_activity(activity_id, {event, participant})
{:ok, participant} {:ok, participant}
end end
@ -619,6 +640,16 @@ defmodule WhoNeedHelp.Activities do
|> preload_activity() |> preload_activity()
end 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 defp get_loaded_activity(id) do
Activity Activity
|> Repo.get(id) |> Repo.get(id)
@ -682,20 +713,24 @@ defmodule WhoNeedHelp.Activities do
Activity Activity
|> where([activity], activity.id == ^id) |> where([activity], activity.id == ^id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
end end
defp locked_participant(id) do defp locked_participant(id) do
Participant Participant
|> where([participant], participant.id == ^id) |> where([participant], participant.id == ^id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
end end
defp maybe_filter_category(query, value) when value in [nil, ""], do: query defp maybe_filter_category(query, value) when value in [nil, ""], do: query
defp maybe_filter_category(query, value), defp maybe_filter_category(query, value) do
do: where(query, [activity], activity.category_id == ^value) 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 defp cast_id(value) do
case Ecto.UUID.cast(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(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), defp broadcast_activity(id, message),
do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "activity:#{id}", message) do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "activity:#{id}", message)
defp member?(%Participant{status: status}), do: status in [:requested, :approved]
end end

View File

@ -63,7 +63,11 @@ defmodule WhoNeedHelp.Activities.Activity do
]) ])
|> validate_length(:title, min: 5, max: 120) |> validate_length(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000) |> 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, |> validate_acceptance(:safety_confirmed,
message: "confirm the safety guidance before publishing" message: "confirm the safety guidance before publishing"
) )
@ -93,8 +97,8 @@ defmodule WhoNeedHelp.Activities.Activity do
latitude = attrs["latitude"] || attrs[:latitude] latitude = attrs["latitude"] || attrs[:latitude]
longitude = attrs["longitude"] || attrs[:longitude] longitude = attrs["longitude"] || attrs[:longitude]
with {lat, ""} <- Float.parse(to_string(latitude)), with {:ok, lat} <- parse_coordinate(latitude),
{lng, ""} <- Float.parse(to_string(longitude)), {:ok, lng} <- parse_coordinate(longitude),
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326}) put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
else else
@ -102,6 +106,18 @@ defmodule WhoNeedHelp.Activities.Activity do
end end
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 defp validate_schedule(changeset) do
starts_at = get_field(changeset, :starts_at) starts_at = get_field(changeset, :starts_at)
join_deadline = get_field(changeset, :join_deadline) 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) defp valid_structured_value?(%{"type" => "select", "options" => options}, value)
when is_list(options) do when is_list(options) do
Enum.any?(options, fn Enum.any?(options, fn
%{"value" => allowed} -> to_string(allowed) == to_string(value) %{"value" => allowed} -> same_scalar_value?(allowed, value)
allowed -> to_string(allowed) == to_string(value) allowed -> same_scalar_value?(allowed, value)
end) end)
end end
@ -320,6 +320,13 @@ defmodule WhoNeedHelp.Catalog do
defp valid_structured_value?(_field, _value), do: false 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"}, "true"), do: true
defp normalize_structured_value(%{"type" => "boolean"}, "false"), do: false defp normalize_structured_value(%{"type" => "boolean"}, "false"), do: false
defp normalize_structured_value(_field, value), do: value defp normalize_structured_value(_field, value), do: value

View File

@ -36,6 +36,10 @@ defmodule WhoNeedHelp.Catalog.Category do
|> validate_required([:slug, :names]) |> validate_required([:slug, :names])
|> validate_format(:slug, ~r/^[a-z0-9-]+$/) |> validate_format(:slug, ~r/^[a-z0-9-]+$/)
|> validate_length(:slug, max: 80) |> 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_names()
|> validate_descriptions() |> validate_descriptions()
|> validate_structured_fields() |> validate_structured_fields()

View File

@ -200,62 +200,69 @@ defmodule WhoNeedHelp.Help do
end end
def accept_request(%Scope{user: helper}, request_id) do def accept_request(%Scope{user: helper}, request_id) do
now = DateTime.utc_now(:second)
code = handover_code(request_id)
result = result =
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) do 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)
Repo.transact(fn -> Repo.transact(fn ->
request = request =
HelpRequest HelpRequest
|> where([r], r.id == ^request_id) |> where([r], r.id == ^request_id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
:ok = Trust.lock_user_pair(request.requester_id, helper.id) if request do
:ok = Trust.lock_user_pair(request.requester_id, helper.id)
cond do cond do
request.requester_id == helper.id -> request.requester_id == helper.id ->
{:error, :own_request} {:error, :own_request}
not is_nil(request.hidden_at) -> not is_nil(request.hidden_at) ->
{:error, :not_open} {:error, :not_open}
Trust.blocked_between?(request.requester_id, helper.id) -> Trust.blocked_between?(request.requester_id, helper.id) ->
{:error, :blocked} {:error, :blocked}
request.status != :open -> request.status != :open ->
{:error, :not_open} {:error, :not_open}
DateTime.compare(request.expires_at, now) != :gt -> DateTime.compare(request.expires_at, now) != :gt ->
{:error, :expired} {:error, :expired}
true -> true ->
with {:ok, assignment} <- with {:ok, assignment} <-
%Assignment{} %Assignment{}
|> Assignment.changeset(%{ |> Assignment.changeset(%{
request_id: request.id, request_id: request.id,
helper_id: helper.id, helper_id: helper.id,
accepted_at: now, accepted_at: now,
handover_code_hash: code_hash(code) handover_code_hash: code_hash(code)
}) })
|> Repo.insert(), |> Repo.insert(),
{:ok, _request} <- {:ok, _request} <-
request |> Ecto.Changeset.change(status: :matched) |> Repo.update(), request |> Ecto.Changeset.change(status: :matched) |> Repo.update(),
{:ok, _audit} <- {:ok, _audit} <-
Trust.audit(helper.id, "request.accepted", "assignment", assignment.id, %{ Trust.audit(helper.id, "request.accepted", "assignment", assignment.id, %{
"request_id" => request.id "request_id" => request.id
}), }),
{:ok, _push_job} <- {:ok, _push_job} <-
Push.enqueue_request_accepted( Push.enqueue_request_accepted(
assignment.id, assignment.id,
request.id, request.id,
request.requester_id request.requester_id
) do ) do
{:ok, assignment} {:ok, assignment}
end end
end
else
{:error, :not_found}
end end
end) end)
else
:error -> {:error, :not_found}
end end
case result do case result do
@ -283,70 +290,86 @@ defmodule WhoNeedHelp.Help do
end end
def verify_handover(%Scope{user: user}, assignment_id, code) do 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 -> Repo.transact(fn ->
assignment = locked_assignment(assignment_id) with %Assignment{} = assignment <- locked_assignment(assignment_id) do
request = Repo.get!(HelpRequest, assignment.request_id) request = Repo.get!(HelpRequest, assignment.request_id)
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id) :ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
cond do cond do
user.id != assignment.helper_id -> user.id != assignment.helper_id ->
{:error, :forbidden} {:error, :forbidden}
Trust.blocked_between?(assignment.helper_id, request.requester_id) -> Trust.blocked_between?(assignment.helper_id, request.requester_id) ->
{:error, :blocked} {:error, :blocked}
assignment.status not in [:accepted, :in_progress] or assignment.status not in [:accepted, :in_progress] or
not is_nil(assignment.handover_verified_at) -> not is_nil(assignment.handover_verified_at) ->
{:error, :invalid_transition} {:error, :invalid_transition}
not Plug.Crypto.secure_compare(code_hash(code), assignment.handover_code_hash) -> not Plug.Crypto.secure_compare(code_hash(code), assignment.handover_code_hash) ->
{:error, :invalid_code} {:error, :invalid_code}
true -> true ->
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
assignment assignment
|> Assignment.changeset(%{handover_verified_at: now}) |> Assignment.changeset(%{handover_verified_at: now})
|> maybe_complete(request) |> maybe_complete(request)
|> audit_assignment_transition(user.id, "handover.verified", request.id) |> audit_assignment_transition(user.id, "handover.verified", request.id)
end
else
nil -> {:error, :not_found}
end end
end) end)
else
:error -> {:error, :not_found}
false -> {:error, :invalid_code}
end end
|> after_transition() |> after_transition()
end end
def cancel_request(%Scope{user: user}, request_id) do 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 -> Repo.transact(fn ->
request = request =
HelpRequest HelpRequest
|> where([r], r.id == ^request_id) |> where([r], r.id == ^request_id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
if request.requester_id == user.id and request.status in [:open, :matched] do cond do
now = DateTime.utc_now(:second) is_nil(request) ->
{:error, :not_found}
assignment = request.requester_id == user.id and request.status in [:open, :matched] ->
Assignment now = DateTime.utc_now(:second)
|> where([assignment], assignment.request_id == ^request.id)
|> lock("FOR UPDATE")
|> Repo.one()
with {:ok, request} <- assignment =
request Assignment
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now) |> where([assignment], assignment.request_id == ^request.id)
|> Repo.update(), |> lock("FOR UPDATE")
{:ok, _assignment} <- cancel_assignment(assignment), |> Repo.one()
{:ok, _audit} <-
Trust.audit(user.id, "request.cancelled", "request", request.id) do with {:ok, request} <-
{:ok, request} request
end |> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
else |> Repo.update(),
{:error, :forbidden} {:ok, _assignment} <- cancel_assignment(assignment),
{:ok, _audit} <-
Trust.audit(user.id, "request.cancelled", "request", request.id) do
{:ok, request}
end
true ->
{:error, :forbidden}
end end
end) end)
else
:error -> {:error, :not_found}
end end
|> case do |> case do
{:ok, request} -> {:ok, request} ->
@ -361,32 +384,39 @@ defmodule WhoNeedHelp.Help do
end end
def withdraw_assignment(%Scope{user: user}, assignment_id) do 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 -> Repo.transact(fn ->
assignment = locked_assignment(assignment_id) with %Assignment{} = assignment <- locked_assignment(assignment_id) do
request = Repo.get!(HelpRequest, assignment.request_id) 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
now = DateTime.utc_now(:second) assignment.status in [:accepted, :in_progress] do
now = DateTime.utc_now(:second)
with {:ok, assignment} <- with {:ok, assignment} <-
assignment assignment
|> Assignment.changeset(%{status: :cancelled}) |> Assignment.changeset(%{status: :cancelled})
|> Repo.update(), |> Repo.update(),
{:ok, _request} <- {:ok, _request} <-
request request
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now) |> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
|> Repo.update(), |> Repo.update(),
{:ok, _audit} <- {:ok, _audit} <-
Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{ Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{
"request_id" => request.id "request_id" => request.id
}) do }) do
{:ok, assignment} {:ok, assignment}
end
else
{:error, :invalid_transition}
end end
else else
{:error, :invalid_transition} nil -> {:error, :not_found}
end end
end) end)
else
:error -> {:error, :not_found}
end end
|> after_transition() |> after_transition()
|> case do |> case do
@ -469,8 +499,8 @@ defmodule WhoNeedHelp.Help do
request.assignment.status in [:accepted, :in_progress] and request.assignment.status in [:accepted, :in_progress] and
not Trust.blocked_between?(scope.user.id, request.requester_id) not Trust.blocked_between?(scope.user.id, request.requester_id)
if (owner or matched_participant) and if owner or
request.location_visibility in [:hidden, :exact_for_active_match] do (matched_participant and request.location_visibility == :exact_for_active_match) do
%Geo.Point{coordinates: {lng, lat}} = request.location %Geo.Point{coordinates: {lng, lat}} = request.location
%{latitude: lat, longitude: lng, exact: true} %{latitude: lat, longitude: lng, exact: true}
else else
@ -490,53 +520,63 @@ defmodule WhoNeedHelp.Help do
end end
defp transition_assignment(user, assignment_id, action) do 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 -> Repo.transact(fn ->
assignment = locked_assignment(assignment_id) with %Assignment{} = assignment <- locked_assignment(assignment_id) do
request = Repo.get!(HelpRequest, assignment.request_id) request = Repo.get!(HelpRequest, assignment.request_id)
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id) :ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
if Trust.blocked_between?(assignment.helper_id, request.requester_id) do if Trust.blocked_between?(assignment.helper_id, request.requester_id) do
{:error, :blocked} {:error, :blocked}
else else
case {action, assignment.status, user.id} do case {action, assignment.status, user.id} do
{:start, :accepted, helper_id} when helper_id == assignment.helper_id -> {:start, :accepted, helper_id} when helper_id == assignment.helper_id ->
with {:ok, assignment} <- with {:ok, assignment} <-
assignment assignment
|> Assignment.changeset(%{status: :in_progress, started_at: now}) |> Assignment.changeset(%{status: :in_progress, started_at: now})
|> Repo.update(), |> Repo.update(),
{:ok, _} <- {:ok, _} <-
request |> Ecto.Changeset.change(status: :in_progress) |> Repo.update(), request |> Ecto.Changeset.change(status: :in_progress) |> Repo.update(),
{:ok, _audit} <- {:ok, _audit} <-
Trust.audit(user.id, "assignment.started", "assignment", assignment.id, %{ Trust.audit(
"request_id" => request.id user.id,
}) do "assignment.started",
{:ok, assignment} "assignment",
end assignment.id,
%{"request_id" => request.id}
{:confirm, status, user_id} when status in [:accepted, :in_progress] -> ) do
attrs = {:ok, assignment}
cond do
user_id == assignment.helper_id -> %{helper_confirmed_at: now}
user_id == request.requester_id -> %{requester_confirmed_at: now}
true -> nil
end end
if attrs do {:confirm, status, user_id} when status in [:accepted, :in_progress] ->
assignment attrs =
|> Assignment.changeset(attrs) cond do
|> maybe_complete(request) user_id == assignment.helper_id -> %{helper_confirmed_at: now}
|> audit_assignment_transition(user.id, "assignment.confirmed", request.id) user_id == request.requester_id -> %{requester_confirmed_at: now}
else true -> nil
{:error, :forbidden} end
end
_ -> if attrs do
{:error, :invalid_transition} assignment
|> Assignment.changeset(attrs)
|> maybe_complete(request)
|> audit_assignment_transition(user.id, "assignment.confirmed", request.id)
else
{:error, :forbidden}
end
_ ->
{:error, :invalid_transition}
end
end end
else
nil -> {:error, :not_found}
end end
end) end)
else
:error -> {:error, :not_found}
end end
|> after_transition() |> after_transition()
end end
@ -571,10 +611,15 @@ defmodule WhoNeedHelp.Help do
Assignment Assignment
|> where([a], a.id == ^id) |> where([a], a.id == ^id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
end 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 defp after_transition({:ok, assignment}) do
request = get_request!(assignment.request_id) 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) when value in [nil, ""], do: query
defp maybe_filter(query, field, value), defp maybe_filter(query, :category_id, value) do
do: where(query, [request], field(request, ^field) == ^value) 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 defp validate_structured_data(changeset) do
category_id = Ecto.Changeset.get_field(changeset, :category_id) 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(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000) |> validate_length(:description, min: 10, max: 2_000)
|> validate_length(:pickup_instructions, max: 1_000) |> validate_length(:pickup_instructions, max: 1_000)
|> validate_length(:location_label, max: 255)
|> validate_acceptance(:safety_confirmed, |> validate_acceptance(:safety_confirmed,
message: "confirm the safety guidance before publishing" message: "confirm the safety guidance before publishing"
) )
@ -80,8 +81,8 @@ defmodule WhoNeedHelp.Help.HelpRequest do
lat = attrs["latitude"] || attrs[:latitude] lat = attrs["latitude"] || attrs[:latitude]
lng = attrs["longitude"] || attrs[:longitude] lng = attrs["longitude"] || attrs[:longitude]
with {lat, ""} <- Float.parse(to_string(lat)), with {:ok, lat} <- parse_coordinate(lat),
{lng, ""} <- Float.parse(to_string(lng)), {:ok, lng} <- parse_coordinate(lng),
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326}) put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
else else
@ -89,6 +90,18 @@ defmodule WhoNeedHelp.Help.HelpRequest do
end end
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 defp validate_expiry(changeset) do
validate_change(changeset, :expires_at, fn :expires_at, value -> validate_change(changeset, :expires_at, fn :expires_at, value ->
if DateTime.after?(value, DateTime.utc_now()), if DateTime.after?(value, DateTime.utc_now()),

View File

@ -53,31 +53,35 @@ defmodule WhoNeedHelp.Messaging do
Assignment Assignment
|> where([current], current.id == ^assignment.id) |> where([current], current.id == ^assignment.id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
|> Repo.preload(:request)
request = current.request if current do
recipient_id = counterpart_id(user.id, current, request) current = Repo.preload(current, :request)
request = current.request
recipient_id = counterpart_id(user.id, current, request)
with true <- Help.participant?(scope, current), with true <- Help.participant?(scope, current),
:ok <- Trust.lock_user_pair(user.id, recipient_id), :ok <- Trust.lock_user_pair(user.id, recipient_id),
false <- Trust.blocked_between?(user.id, recipient_id), false <- Trust.blocked_between?(user.id, recipient_id),
{:ok, message} <- {:ok, message} <-
%Message{assignment_id: current.id, sender_id: user.id} %Message{assignment_id: current.id, sender_id: user.id}
|> Message.changeset(attrs) |> Message.changeset(attrs)
|> Repo.insert(), |> Repo.insert(),
{:ok, _push_job} <- {:ok, _push_job} <-
Push.enqueue_message_created( Push.enqueue_message_created(
message.id, message.id,
current.id, current.id,
request.id, request.id,
recipient_id recipient_id
) do ) do
{:ok, message} {:ok, message}
else
true -> {:error, :blocked}
false -> {:error, :forbidden}
other -> other
end
else else
true -> {:error, :blocked} {:error, :not_found}
false -> {:error, :forbidden}
other -> other
end end
end) end)

View File

@ -26,12 +26,24 @@ defmodule WhoNeedHelp.Tracking.Position do
lat = attrs["latitude"] || attrs[:latitude] lat = attrs["latitude"] || attrs[:latitude]
lng = attrs["longitude"] || attrs[:longitude] lng = attrs["longitude"] || attrs[:longitude]
with {lat, ""} <- Float.parse(to_string(lat)), with {:ok, lat} <- parse_coordinate(lat),
{lng, ""} <- Float.parse(to_string(lng)), {:ok, lng} <- parse_coordinate(lng),
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
put_change(changeset, :position, %Geo.Point{coordinates: {lng, lat}, srid: 4326}) put_change(changeset, :position, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
else else
_ -> add_error(changeset, :position, "is invalid") _ -> add_error(changeset, :position, "is invalid")
end end
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 end

View File

@ -53,47 +53,51 @@ defmodule WhoNeedHelp.Trust do
Assignment Assignment
|> where([current], current.id == ^assignment.id) |> where([current], current.id == ^assignment.id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
request = Repo.get!(HelpRequest, current.request_id) if current do
request = Repo.get!(HelpRequest, current.request_id)
cond do cond do
current.status != :completed -> current.status != :completed ->
{:error, :forbidden} {:error, :forbidden}
user.id not in [current.helper_id, request.requester_id] -> user.id not in [current.helper_id, request.requester_id] ->
{:error, :forbidden} {:error, :forbidden}
true -> true ->
reviewee_id = reviewee_id =
if user.id == current.helper_id, if user.id == current.helper_id,
do: request.requester_id, do: request.requester_id,
else: current.helper_id else: current.helper_id
with {:ok, review} <- with {:ok, review} <-
%Review{} %Review{}
|> Review.changeset( |> Review.changeset(
Map.merge(attrs, %{ Map.merge(attrs, %{
"assignment_id" => current.id, "assignment_id" => current.id,
"reviewer_id" => user.id, "reviewer_id" => user.id,
"reviewee_id" => reviewee_id "reviewee_id" => reviewee_id
}) })
) )
|> Repo.insert(), |> Repo.insert(),
reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id), reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id),
true <- length(reviews) <= 2, true <- length(reviews) <= 2,
revealed? <- length(reviews) == 2, revealed? <- length(reviews) == 2,
{_count, _rows} <- {_count, _rows} <-
maybe_reveal_reviews(current.id, revealed?), maybe_reveal_reviews(current.id, revealed?),
{:ok, _audit} <- {:ok, _audit} <-
audit(user.id, "review.submitted", "assignment", current.id, %{ audit(user.id, "review.submitted", "assignment", current.id, %{
"revealed" => revealed? "revealed" => revealed?
}) do }) do
{:ok, review} {:ok, review}
else else
false -> {:error, :invalid_review_count} false -> {:error, :invalid_review_count}
other -> other other -> other
end end
end
else
{:error, :not_found}
end end
end) end)
end end
@ -870,7 +874,7 @@ defmodule WhoNeedHelp.Trust do
defp users_by_id([]), do: %{} defp users_by_id([]), do: %{}
defp users_by_id(ids) do defp users_by_id(ids) do
User Accounts.public_user_query()
|> where([user], user.id in ^ids) |> where([user], user.id in ^ids)
|> Repo.all() |> Repo.all()
|> Map.new(&{&1.id, &1}) |> Map.new(&{&1.id, &1})
@ -1059,51 +1063,59 @@ defmodule WhoNeedHelp.Trust do
defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id}) defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id})
when is_binary(request_id) do when is_binary(request_id) do
case Repo.get(HelpRequest, request_id) do with {:ok, request_id} <- cast_id(request_id) do
%HelpRequest{requester_id: requester_id} when requester_id != user.id -> :ok case Repo.get(HelpRequest, request_id) do
%HelpRequest{} -> {:error, :cannot_report_self} %HelpRequest{requester_id: requester_id} when requester_id != user.id -> :ok
nil -> {:error, :not_found} %HelpRequest{} -> {:error, :cannot_report_self}
nil -> {:error, :not_found}
end
end end
end end
defp authorize_report_target(scope, %{"assignment_id" => assignment_id}) defp authorize_report_target(scope, %{"assignment_id" => assignment_id})
when is_binary(assignment_id) do when is_binary(assignment_id) do
case Repo.get(Assignment, assignment_id) do with {:ok, assignment_id} <- cast_id(assignment_id) do
%Assignment{} = assignment -> case Repo.get(Assignment, assignment_id) do
if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden} %Assignment{} = assignment ->
if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden}
nil -> nil ->
{:error, :not_found} {:error, :not_found}
end
end end
end end
defp authorize_report_target(scope, %{"message_id" => message_id}) when is_binary(message_id) do defp authorize_report_target(scope, %{"message_id" => message_id}) when is_binary(message_id) do
case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do with {:ok, message_id} <- cast_id(message_id) do
%Message{sender_id: sender_id, assignment: assignment} -> case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do
cond do %Message{sender_id: sender_id, assignment: assignment} ->
sender_id == scope.user.id -> {:error, :cannot_report_self} cond do
Help.participant?(scope, assignment) -> :ok sender_id == scope.user.id -> {:error, :cannot_report_self}
true -> {:error, :forbidden} Help.participant?(scope, assignment) -> :ok
end true -> {:error, :forbidden}
end
nil -> nil ->
{:error, :not_found} {:error, :not_found}
end
end end
end end
defp authorize_report_target(%Scope{user: user}, %{"activity_id" => activity_id}) defp authorize_report_target(%Scope{user: user}, %{"activity_id" => activity_id})
when is_binary(activity_id) do when is_binary(activity_id) do
case Repo.get(Activity, activity_id) do with {:ok, activity_id} <- cast_id(activity_id) do
%Activity{creator_id: creator_id} when creator_id == user.id -> case Repo.get(Activity, activity_id) do
{:error, :cannot_report_self} %Activity{creator_id: creator_id} when creator_id == user.id ->
{:error, :cannot_report_self}
%Activity{} = activity -> %Activity{} = activity ->
if is_nil(activity.hidden_at) or activity_participant?(activity.id, user.id), if is_nil(activity.hidden_at) or activity_participant?(activity.id, user.id),
do: :ok, do: :ok,
else: {:error, :not_found} else: {:error, :not_found}
nil -> nil ->
{:error, :not_found} {:error, :not_found}
end
end end
end end
@ -1112,17 +1124,19 @@ defmodule WhoNeedHelp.Trust do
%{"activity_message_id" => message_id} %{"activity_message_id" => message_id}
) )
when is_binary(message_id) do when is_binary(message_id) do
case Repo.get(ActivityMessage, message_id) do with {:ok, message_id} <- cast_id(message_id) do
%ActivityMessage{sender_id: sender_id} when sender_id == user.id -> case Repo.get(ActivityMessage, message_id) do
{:error, :cannot_report_self} %ActivityMessage{sender_id: sender_id} when sender_id == user.id ->
{:error, :cannot_report_self}
%ActivityMessage{activity_id: activity_id} -> %ActivityMessage{activity_id: activity_id} ->
if activity_participant?(activity_id, user.id), if activity_participant?(activity_id, user.id),
do: :ok, do: :ok,
else: {:error, :forbidden} else: {:error, :forbidden}
nil -> nil ->
{:error, :not_found} {:error, :not_found}
end
end end
end end
@ -1195,18 +1209,16 @@ defmodule WhoNeedHelp.Trust do
) )
|> where( |> where(
[assignment, request], [assignment, request],
(assignment.helper_id == ^first_user_id and request.requester_id == ^second_user_id) or assignment.status in [:accepted, :in_progress] and
(assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id) ((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() |> Repo.all()
active_assignment_ids = active_assignment_ids = Enum.map(assignments, &elem(&1, 0))
assignments
|> Enum.filter(fn {_assignment_id, _request_id, status} ->
status in [:accepted, :in_progress]
end)
|> Enum.map(&elem(&1, 0))
active_sessions = active_sessions =
if active_assignment_ids == [] do if active_assignment_ids == [] do

View File

@ -30,7 +30,11 @@ defmodule WhoNeedHelp.Trust.RateLimiter do
defp policy(action) do defp policy(action) do
policies = Application.get_env(:who_need_help, :rate_limit_policies, %{}) 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 case raw do
%{limit: limit, window_seconds: window} %{limit: limit, window_seconds: window}

View File

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

View File

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

View File

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

View File

@ -1,40 +1,44 @@
defmodule WhoNeedHelpWeb.UserRegistrationController do defmodule WhoNeedHelpWeb.UserRegistrationController do
use WhoNeedHelpWeb, :controller use WhoNeedHelpWeb, :controller
require Logger
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.User alias WhoNeedHelp.Accounts.User
alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelp.Trust.RateLimiter
plug :put_no_store
def new(conn, _params) do def new(conn, _params) do
changeset = Accounts.change_user_registration(%User{}, %{}, validate_unique: false) changeset = Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
render(conn, :new, changeset: changeset) render(conn, :new, changeset: changeset)
end end
def create(conn, %{"user" => user_params}) do def create(conn, %{"user" => user_params}) when is_map(user_params) do
email_scope = user_params["email"] |> to_string() |> String.trim() |> String.downcase() user_params = normalize_registration_params(user_params)
email_scope = normalize_email_scope(user_params["email"])
with {:ok, _limit} <- RateLimiter.check(:registration_email, email_scope), with {:ok, _limit} <- RateLimiter.check(:registration_email, email_scope),
result <- Accounts.register_user(user_params) do result <- Accounts.register_user(user_params) do
case result do case result do
{:ok, user} -> {:ok, user} ->
{:ok, _} = deliver_registration_instructions(conn, user)
Accounts.deliver_login_instructions( registration_response(conn)
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")
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, 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 end
else else
{:error, :rate_limited} -> {:error, :rate_limited} ->
@ -49,4 +53,76 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
) )
end end
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 end

View File

@ -1,10 +1,15 @@
defmodule WhoNeedHelpWeb.UserSessionController do defmodule WhoNeedHelpWeb.UserSessionController do
use WhoNeedHelpWeb, :controller use WhoNeedHelpWeb, :controller
require Logger
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.UserAuth alias WhoNeedHelpWeb.UserAuth
plug :assign_magic_link_form
plug :put_no_store
def new(conn, _params) do def new(conn, _params) do
email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)]) email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
form = Phoenix.Component.to_form(%{"email" => email}, as: "user") form = Phoenix.Component.to_form(%{"email" => email}, as: "user")
@ -13,7 +18,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do
end end
# magic link login # 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 = info =
case params do case params do
%{"_action" => "confirmed"} -> gettext("User confirmed successfully.") %{"_action" => "confirmed"} -> gettext("User confirmed successfully.")
@ -34,7 +40,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do
end end
# email + password login # 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() email_scope = email |> String.trim() |> String.downcase()
case RateLimiter.check(:password_login_email, email_scope) do case RateLimiter.check(:password_login_email, email_scope) do
@ -56,17 +63,23 @@ defmodule WhoNeedHelpWeb.UserSessionController do
end end
# magic link request # 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() email_scope = email |> String.trim() |> String.downcase()
case RateLimiter.check(:magic_link_email, email_scope) do case RateLimiter.check(:magic_link_email, email_scope) do
{:ok, _limit} -> {:ok, _limit} ->
case Accounts.get_user_by_email(email) do case Accounts.get_user_by_email(email) do
%Accounts.User{moderation_status: status} = user when status != :suspended -> %Accounts.User{moderation_status: status} = user when status != :suspended ->
Accounts.deliver_login_instructions( case Accounts.deliver_login_instructions(
user, 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 -> _missing_or_suspended ->
:ok :ok
@ -92,19 +105,11 @@ defmodule WhoNeedHelpWeb.UserSessionController do
end end
end end
def confirm(conn, %{"token" => token}) do def create(conn, _params) do
if user = Accounts.get_user_by_magic_link_token(token) do conn
form = Phoenix.Component.to_form(%{"token" => token}, as: "user") |> put_status(:bad_request)
|> put_flash(:error, gettext("The sign-in form is invalid."))
conn |> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
|> 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
end end
def delete(conn, _params) do def delete(conn, _params) do
@ -119,4 +124,14 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|> put_flash(:error, gettext("Invalid email or password")) |> put_flash(:error, gettext("Invalid email or password"))
|> render(:new, form: Phoenix.Component.to_form(user_params, as: "user")) |> render(:new, form: Phoenix.Component.to_form(user_params, as: "user"))
end 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 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,47 +30,81 @@
</div> </div>
</div> </div>
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}> <.form
<.input for={@magic_link_form}
readonly={!!@current_scope} id="magic-link-fragment-form"
field={f[:email]} action={~p"/users/log-in?_action=confirmed"}
type="email" hidden
label={gettext("Email")} >
autocomplete="username" <input
spellcheck="false" id="magic-link-fragment-token"
required type="hidden"
phx-mounted={JS.focus()} name={@magic_link_form[:token].name}
value=""
/> />
<.button class="btn btn-primary w-full"> <p class="alert alert-info mb-4">
{gettext("Log in with email")} <span aria-hidden="true">→</span> {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> </.button>
</.form> </.form>
<div class="divider">{gettext("or")}</div> <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}
field={f[:email]}
type="email"
label={gettext("Email")}
autocomplete="username"
spellcheck="false"
required
phx-mounted={JS.focus()}
/>
<.button class="btn btn-primary w-full">
{gettext("Log in with email")} <span aria-hidden="true">→</span>
</.button>
</.form>
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}> <div class="divider">{gettext("or")}</div>
<.input
readonly={!!@current_scope} <.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
field={f[:email]} <.input
type="email" readonly={!!@current_scope}
label={gettext("Email")} field={f[:email]}
autocomplete="username" type="email"
spellcheck="false" label={gettext("Email")}
required autocomplete="username"
/> spellcheck="false"
<.input required
field={f[:password]} />
type="password" <.input
label={gettext("Password")} field={f[:password]}
autocomplete="current-password" type="password"
spellcheck="false" label={gettext("Password")}
/> autocomplete="current-password"
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true"> spellcheck="false"
{gettext("Log in and stay logged in")} <span aria-hidden="true">→</span> />
</.button> <.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
<.button class="btn btn-primary btn-soft w-full mt-2"> {gettext("Log in and stay logged in")} <span aria-hidden="true">→</span>
{gettext("Log in only this time")} </.button>
</.button> <.button class="btn btn-primary btn-soft w-full mt-2">
</.form> {gettext("Log in only this time")}
</.button>
</.form>
</div>
</div> </div>
</Layouts.app> </Layouts.app>

View File

@ -1,44 +1,54 @@
defmodule WhoNeedHelpWeb.UserSettingsController do defmodule WhoNeedHelpWeb.UserSettingsController do
use WhoNeedHelpWeb, :controller use WhoNeedHelpWeb, :controller
require Logger
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.UserAuth alias WhoNeedHelpWeb.UserAuth
import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2] import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2]
plug :require_sudo_mode plug :require_sudo_mode when action in [:edit, :update]
plug :assign_email_and_password_changesets plug :assign_email_and_password_changesets when action in [:edit, :update]
def edit(conn, _params) do def edit(conn, _params) do
render(conn, :edit) render(conn, :edit)
end end
def update(conn, %{"action" => "update_email"} = params) do def update(conn, %{"action" => "update_email", "user" => user_params})
%{"user" => user_params} = params when is_map(user_params) do
user = conn.assigns.current_scope.user user = conn.assigns.current_scope.user
case Accounts.change_user_email(user, user_params) do case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset -> %{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions( new_email =
Ecto.Changeset.apply_action!(changeset, :insert), changeset
user.email, |> Ecto.Changeset.get_change(:email, "")
&url(~p"/users/settings/confirm-email/#{&1}") |> String.trim()
) |> String.downcase()
conn case RateLimiter.check(:email_change_email, new_email) do
|> put_flash( {:ok, _limit} ->
:info, deliver_email_change_instructions(conn, user, changeset)
gettext("A link to confirm your email change has been sent to the new address.")
) {:error, :rate_limited} ->
|> redirect(to: ~p"/users/settings") conn
|> put_status(:too_many_requests)
|> put_flash(
:error,
gettext("Too many email-change requests in the configured time window.")
)
|> render(:edit)
end
changeset -> changeset ->
render(conn, :edit, email_changeset: %{changeset | action: :insert}) render(conn, :edit, email_changeset: %{changeset | action: :insert})
end end
end end
def update(conn, %{"action" => "update_password"} = params) do def update(conn, %{"action" => "update_password", "user" => user_params})
%{"user" => user_params} = params when is_map(user_params) do
user = conn.assigns.current_scope.user user = conn.assigns.current_scope.user
case Accounts.update_user_password(user, user_params) do case Accounts.update_user_password(user, user_params) do
@ -56,7 +66,18 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
end end
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 case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
{:ok, _user} -> {:ok, _user} ->
conn conn
@ -70,6 +91,12 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
end end
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 defp assign_email_and_password_changesets(conn, _opts) do
user = conn.assigns.current_scope.user user = conn.assigns.current_scope.user
@ -77,4 +104,30 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|> assign(:email_changeset, Accounts.change_user_email(user)) |> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user)) |> assign(:password_changeset, Accounts.change_user_password(user))
end 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 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, store: :cookie,
key: "_who_need_help_key", key: "_who_need_help_key",
signing_salt: "XN+IwK3w", 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, socket "/live", Phoenix.LiveView.Socket,
@ -45,8 +46,7 @@ defmodule WhoNeedHelpWeb.Endpoint do
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers, plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json], parsers: [:urlencoded, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library() json_decoder: Phoenix.json_library()
plug Plug.MethodOverride plug Plug.MethodOverride

View File

@ -1,12 +1,15 @@
defmodule WhoNeedHelpWeb.ActivityLive.Index do defmodule WhoNeedHelpWeb.ActivityLive.Index do
use WhoNeedHelpWeb, :live_view use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Activities, Catalog} alias WhoNeedHelp.{Activities, Catalog, Pagination}
alias WhoNeedHelp.Activities.Activity alias WhoNeedHelp.Activities.Activity
@impl true @impl true
def mount(_params, _session, socket) do 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, {:ok,
socket socket
@ -18,8 +21,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
def handle_info({event, activity}, socket) def handle_info({event, activity}, socket)
when event in [:activity_created, :activity_updated] do when event in [:activity_created, :activity_updated] do
user_id = socket.assigns.current_scope.user.id 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( update_entry(
socket.assigns.activities, socket.assigns.activities,
activity, activity,
@ -28,24 +32,44 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
activity, activity,
socket.assigns.filters socket.assigns.filters
), ),
:asc :asc,
socket.assigns.activities_cursor
) )
my_activities = {my_activities, my_activities_cursor} =
update_entry( update_entry(
socket.assigns.my_activities, socket.assigns.my_activities,
activity, activity,
Activities.member_activity?(activity, user_id), already_mine? or activity.creator_id == user_id,
:desc :desc,
socket.assigns.my_activities_cursor
) )
{:noreply, {:noreply,
socket socket
|> assign(:activities, activities) |> assign(:activities, activities)
|> assign(:activities_cursor, activities_cursor)
|> assign(:my_activities, my_activities) |> assign(:my_activities, my_activities)
|> assign(:my_activities_cursor, my_activities_cursor)
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))} |> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))}
end 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 @impl true
def handle_event("filter", %{"filters" => filters}, socket) do def handle_event("filter", %{"filters" => filters}, socket) do
{:noreply, socket |> assign(:filters, filters) |> load()} {: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)) existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
end 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 = Enum.reject(entries, &(&1.id == activity.id))
entries = if visible?, do: [activity | entries], else: entries 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 end
defp marker(activity) do defp marker(activity) do
@ -195,6 +232,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
<.link <.link
:for={activity <- @activities} :for={activity <- @activities}
id={"open-activity-#{activity.id}"}
navigate={~p"/activities/#{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" 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"> <div class="mt-4 grid gap-3 md:grid-cols-2">
<.link <.link
:for={activity <- @my_activities} :for={activity <- @my_activities}
id={"my-activity-#{activity.id}"}
navigate={~p"/activities/#{activity.id}"} navigate={~p"/activities/#{activity.id}"}
class="rounded-2xl bg-base-200 p-4" 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 option_value(option), do: option
defp structured_value(form, key) do 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 end
defp error_message(:account_not_eligible), defp error_message(:account_not_eligible),

View File

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

View File

@ -100,7 +100,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
} }
|> Map.reject(fn {_locale, description} -> description in [nil, ""] end) |> 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 true <- is_map(structured_fields) do
attrs = %{ attrs = %{
"slug" => params["slug"], "slug" => params["slug"],
@ -190,6 +190,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
{:noreply, put_flash(socket, :error, error_message(reason))} {:noreply, put_flash(socket, :error, error_message(reason))}
end 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 defp load(socket) do
reports = Trust.paginate_reports(socket.assigns.current_scope) reports = Trust.paginate_reports(socket.assigns.current_scope)
signals = Trust.paginate_abuse_signals(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 def handle_event("add-social", %{"social_identity" => params}, socket) do
user = socket.assigns.current_scope.user user = socket.assigns.current_scope.user
case Accounts.add_social_identity(user, params) do with {:ok, _limit} <- Trust.authorize_action(socket.assigns.current_scope, :add_social),
{:ok, _identity} -> {:ok, _identity} <- Accounts.add_social_identity(user, params) do
{:noreply, {:noreply,
socket socket
|> assign_social_identities(user) |> assign_social_identities(user)
|> put_flash(:info, gettext("Social link added as unverified."))} |> put_flash(:info, gettext("Social link added as unverified."))}
else
{:error, changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :social_form, to_form(changeset, as: :social_identity))} {: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
end end
@ -273,7 +280,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
<a <a
href={identity.profile_url} href={identity.profile_url}
target="_blank" target="_blank"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow ugc"
class="link mt-1 block truncate text-sm" class="link mt-1 block truncate text-sm"
> >
{identity.handle || identity.profile_url} {identity.handle || identity.profile_url}

View File

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

View File

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

View File

@ -3,13 +3,19 @@ defmodule WhoNeedHelpWeb.Router do
import WhoNeedHelpWeb.UserAuth 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 pipeline :browser do
plug :accepts, ["html"] plug :accepts, ["html"]
plug :fetch_session plug :fetch_session
plug :fetch_live_flash plug :fetch_live_flash
plug :put_root_layout, html: {WhoNeedHelpWeb.Layouts, :root} plug :put_root_layout, html: {WhoNeedHelpWeb.Layouts, :root}
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers plug :put_secure_browser_headers, @secure_browser_headers
plug :put_content_security_policy plug :put_content_security_policy
plug :fetch_current_scope_for_user plug :fetch_current_scope_for_user
plug :put_authenticated_cache_policy plug :put_authenticated_cache_policy
@ -24,7 +30,7 @@ defmodule WhoNeedHelpWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
plug :fetch_session plug :fetch_session
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers plug :put_secure_browser_headers, @secure_browser_headers
plug :put_content_security_policy plug :put_content_security_policy
plug :fetch_current_scope_for_user plug :fetch_current_scope_for_user
plug :put_authenticated_cache_policy plug :put_authenticated_cache_policy
@ -104,7 +110,8 @@ defmodule WhoNeedHelpWeb.Router do
get "/users/settings", UserSettingsController, :edit get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update 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", SocialOAuthController, :request
get "/auth/social/:provider/callback", SocialOAuthController, :callback get "/auth/social/:provider/callback", SocialOAuthController, :callback
end end
@ -135,7 +142,6 @@ defmodule WhoNeedHelpWeb.Router do
pipe_through [:browser] pipe_through [:browser]
get "/users/log-in", UserSessionController, :new get "/users/log-in", UserSessionController, :new
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete delete "/users/log-out", UserSessionController, :delete
end end

View File

@ -15,7 +15,8 @@ defmodule WhoNeedHelpWeb.UserAuth do
@remember_me_options [ @remember_me_options [
sign: true, sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60, 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 # 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", "must be in the future"),
dgettext_noop("errors", "select an activity category"), dgettext_noop("errors", "select an activity category"),
dgettext_noop("errors", "must be on or before the start time"), 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", "must be a GitHub profile URL"),
dgettext_noop("errors", "you must confirm that you are 18+ and accept the rules"), dgettext_noop("errors", "you must confirm that you are 18+ and accept the rules"),
dgettext_noop("errors", "must have the @ sign and no spaces"), 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" msgid "contains an invalid field definition"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:22 #: lib/who_need_help_web/validation_messages.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "did not change" msgid "did not change"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:23 #: lib/who_need_help_web/validation_messages.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "does not match password" msgid "does not match password"
msgstr "" msgstr ""
@ -131,16 +131,11 @@ msgstr ""
msgid "field keys must be unique" msgid "field keys must be unique"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:19 #: lib/who_need_help_web/validation_messages.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must be a GitHub profile URL" msgid "must be a GitHub profile URL"
msgstr "" 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 #: lib/who_need_help_web/validation_messages.ex:12
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must be an object with a fields array" msgid "must be an object with a fields array"
@ -166,7 +161,7 @@ msgstr ""
msgid "must contain non-empty localized descriptions" msgid "must contain non-empty localized descriptions"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:21 #: lib/who_need_help_web/validation_messages.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces" msgid "must have the @ sign and no spaces"
msgstr "" msgstr ""
@ -186,7 +181,17 @@ msgstr ""
msgid "select exactly one report target" msgid "select exactly one report target"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:20 #: lib/who_need_help_web/validation_messages.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "you must confirm that you are 18+ and accept the rules" msgid "you must confirm that you are 18+ and accept the rules"
msgstr "" 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" msgid "contains an invalid field definition"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:22 #: lib/who_need_help_web/validation_messages.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "did not change" msgid "did not change"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:23 #: lib/who_need_help_web/validation_messages.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "does not match password" msgid "does not match password"
msgstr "" msgstr ""
@ -128,16 +128,11 @@ msgstr ""
msgid "field keys must be unique" msgid "field keys must be unique"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:19 #: lib/who_need_help_web/validation_messages.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must be a GitHub profile URL" msgid "must be a GitHub profile URL"
msgstr "" 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 #: lib/who_need_help_web/validation_messages.ex:12
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must be an object with a fields array" msgid "must be an object with a fields array"
@ -163,7 +158,7 @@ msgstr ""
msgid "must contain non-empty localized descriptions" msgid "must contain non-empty localized descriptions"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:21 #: lib/who_need_help_web/validation_messages.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces" msgid "must have the @ sign and no spaces"
msgstr "" msgstr ""
@ -183,7 +178,17 @@ msgstr ""
msgid "select exactly one report target" msgid "select exactly one report target"
msgstr "" msgstr ""
#: lib/who_need_help_web/validation_messages.ex:20 #: lib/who_need_help_web/validation_messages.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "you must confirm that you are 18+ and accept the rules" msgid "you must confirm that you are 18+ and accept the rules"
msgstr "" 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" msgid "contains an invalid field definition"
msgstr "содержит неверное определение поля" msgstr "содержит неверное определение поля"
#: lib/who_need_help_web/validation_messages.ex:22 #: lib/who_need_help_web/validation_messages.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "did not change" msgid "did not change"
msgstr "не изменилось" msgstr "не изменилось"
#: lib/who_need_help_web/validation_messages.ex:23 #: lib/who_need_help_web/validation_messages.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "does not match password" msgid "does not match password"
msgstr "не совпадает с паролем" msgstr "не совпадает с паролем"
@ -130,16 +130,11 @@ msgstr "не совпадает с паролем"
msgid "field keys must be unique" msgid "field keys must be unique"
msgstr "ключи полей должны быть уникальными" msgstr "ключи полей должны быть уникальными"
#: lib/who_need_help_web/validation_messages.ex:19 #: lib/who_need_help_web/validation_messages.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must be a GitHub profile URL" msgid "must be a GitHub profile URL"
msgstr "должно быть URL профиля GitHub" 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 #: lib/who_need_help_web/validation_messages.ex:12
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must be an object with a fields array" msgid "must be an object with a fields array"
@ -165,7 +160,7 @@ msgstr "должно содержать хотя бы одно непустое
msgid "must contain non-empty localized descriptions" msgid "must contain non-empty localized descriptions"
msgstr "должно содержать непустые локализованные описания" msgstr "должно содержать непустые локализованные описания"
#: lib/who_need_help_web/validation_messages.ex:21 #: lib/who_need_help_web/validation_messages.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces" msgid "must have the @ sign and no spaces"
msgstr "должно содержать знак @ и не содержать пробелов" msgstr "должно содержать знак @ и не содержать пробелов"
@ -185,7 +180,17 @@ msgstr "выберите категорию активности"
msgid "select exactly one report target" msgid "select exactly one report target"
msgstr "выберите ровно один объект жалобы" msgstr "выберите ровно один объект жалобы"
#: lib/who_need_help_web/validation_messages.ex:20 #: lib/who_need_help_web/validation_messages.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "you must confirm that you are 18+ and accept the rules" msgid "you must confirm that you are 18+ and accept the rules"
msgstr "подтвердите, что вам исполнилось 18 лет и вы принимаете правила" 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" msgid "contains an invalid field definition"
msgstr "містить неправильне визначення поля" msgstr "містить неправильне визначення поля"
#: lib/who_need_help_web/validation_messages.ex:22 #: lib/who_need_help_web/validation_messages.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "did not change" msgid "did not change"
msgstr "не змінилося" msgstr "не змінилося"
#: lib/who_need_help_web/validation_messages.ex:23 #: lib/who_need_help_web/validation_messages.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "does not match password" msgid "does not match password"
msgstr "не збігається з паролем" msgstr "не збігається з паролем"
@ -130,16 +130,11 @@ msgstr "не збігається з паролем"
msgid "field keys must be unique" msgid "field keys must be unique"
msgstr "ключі полів мають бути унікальними" msgstr "ключі полів мають бути унікальними"
#: lib/who_need_help_web/validation_messages.ex:19 #: lib/who_need_help_web/validation_messages.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must be a GitHub profile URL" msgid "must be a GitHub profile URL"
msgstr "має бути URL-адресою профілю GitHub" 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 #: lib/who_need_help_web/validation_messages.ex:12
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must be an object with a fields array" msgid "must be an object with a fields array"
@ -165,7 +160,7 @@ msgstr "має містити щонайменше одну непорожню
msgid "must contain non-empty localized descriptions" msgid "must contain non-empty localized descriptions"
msgstr "має містити непорожні локалізовані описи" msgstr "має містити непорожні локалізовані описи"
#: lib/who_need_help_web/validation_messages.ex:21 #: lib/who_need_help_web/validation_messages.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "must have the @ sign and no spaces" msgid "must have the @ sign and no spaces"
msgstr "має містити знак @ і не містити пробілів" msgstr "має містити знак @ і не містити пробілів"
@ -185,7 +180,17 @@ msgstr "виберіть категорію активності"
msgid "select exactly one report target" msgid "select exactly one report target"
msgstr "виберіть рівно один об’єкт скарги" msgstr "виберіть рівно один об’єкт скарги"
#: lib/who_need_help_web/validation_messages.ex:20 #: lib/who_need_help_web/validation_messages.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "you must confirm that you are 18+ and accept the rules" msgid "you must confirm that you are 18+ and accept the rules"
msgstr "підтвердьте, що вам виповнилося 18 років і ви приймаєте правила" 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" TEST_ENV="$ROOT/.env.android-test"
run_id=$(date -u +%Y%m%d%H%M%S)-$$ run_id=$(date -u +%Y%m%d%H%M%S)-$$
android_api=${WNH_ANDROID_TEST_API:-37.0} android_api=${WNH_ANDROID_TEST_API:-37.0}
# 1G is measured against the complete API 30/34/37 suite. It can still be # 1G is measured against the API 30/34/37 suite and is also exercised by the
# overridden for a future test that intentionally stores more device data. # 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} android_data_partition_size=${WNH_ANDROID_TEST_DATA_PARTITION_SIZE:-1G}
if ! printf '%s\n' "$android_data_partition_size" | if ! printf '%s\n' "$android_data_partition_size" |
@ -17,6 +18,9 @@ if ! printf '%s\n' "$android_data_partition_size" |
fi fi
case "$android_api" in case "$android_api" in
24)
android_system_image="system-images/android-24/google_apis/x86_64"
;;
30) 30)
android_system_image="system-images/android-30/google_apis/x86_64" 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" 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 exit 1
;; ;;
esac esac
@ -100,6 +104,12 @@ cleanup() {
docker exec "$container" adb logcat -d > "$output/logcat.txt" 2>&1 || true docker exec "$container" adb logcat -d > "$output/logcat.txt" 2>&1 || true
docker exec "$container" adb shell dumpsys activity services \ docker exec "$container" adb shell dumpsys activity services \
org.whoneedhelp.mobile.debug > "$output/services.txt" 2>&1 || true 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 docker logs "$container" > "$output/emulator.log" 2>&1 || true
fi 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 window_animation_scale 0
docker exec "$container" adb shell settings put global transition_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 docker exec "$container" adb shell settings put global animator_duration_scale 0
docker exec "$container" adb shell cmd location set-location-enabled true 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 emu geo fix -122.084000 37.422000
docker exec "$container" adb install -r \ docker exec "$container" adb install -r \
/opt/who-need-help/who-need-help-debug.apk /opt/who-need-help/who-need-help-debug.apk

View File

@ -2,11 +2,11 @@
set -eu set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) 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 for api in $matrix; do
case "$api" in case "$api" in
30|34|37.0) ;; 24|30|34|37.0) ;;
*) *)
echo "WNH_ANDROID_TEST_API_MATRIX contains unsupported API: $api" >&2 echo "WNH_ANDROID_TEST_API_MATRIX contains unsupported API: $api" >&2
exit 1 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_RECOVERY_TIMEOUT_SECONDS \
LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS \ LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS \
LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \ LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \
HTTP_PORT POSTGRES_DB; do HTTP_PORT POSTGRES_DB METRICS_TOKEN; do
if [[ -z "${!name:-}" ]]; then if [[ -z "${!name:-}" ]]; then
echo "$name is missing from .env.load" >&2 echo "$name is missing from .env.load" >&2
exit 1 exit 1
@ -184,6 +184,7 @@ sample_readiness() {
observed_at=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ) observed_at=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
body_file="$output_dir/readiness-body.$$" body_file="$output_dir/readiness-body.$$"
error_file="$output_dir/readiness-error.$$" error_file="$output_dir/readiness-error.$$"
metrics_headers_file="$output_dir/metrics-headers.$$"
set +e set +e
status=$( status=$(
curl --silent --show-error \ curl --silent --show-error \
@ -206,15 +207,55 @@ sample_readiness() {
body= body=
fi 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") error=$(tr -d '\n' <"$error_file")
unlink "$body_file" 2>/dev/null || true unlink "$body_file" 2>/dev/null || true
unlink "$error_file" 2>/dev/null || true unlink "$error_file" 2>/dev/null || true
unlink "$metrics_headers_file" 2>/dev/null || true
jq -cn \ jq -cn \
--arg observed_at "$observed_at" \ --arg observed_at "$observed_at" \
--arg status "$status" \ --arg status "$status" \
--arg metrics_status "$metrics_status" \
--arg node "$node" \
--arg body "$body" \ --arg body "$body" \
--arg error "$error" \ --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" >>"$probe_log"
sleep "$LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS" sleep "$LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS"
done done
@ -338,7 +379,7 @@ mapfile -t original_web_ids < <(service_ids web)
for container_id in "${original_web_ids[@]}"; do for container_id in "${original_web_ids[@]}"; do
assert_scope "$container_id" web assert_scope "$container_id" web
{ {
docker stop --time 30 "$container_id" docker stop --timeout 30 "$container_id"
docker rm "$container_id" docker rm "$container_id"
"${compose[@]}" up -d --no-deps --scale "web=$LOAD_WEB_REPLICAS" web "${compose[@]}" up -d --no-deps --scale "web=$LOAD_WEB_REPLICAS" web
} >>"$output_dir/web-replacements.txt" } >>"$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 for container_id in "${original_worker_ids[@]}"; do
assert_scope "$container_id" worker assert_scope "$container_id" worker
{ {
docker stop --time 30 "$container_id" docker stop --timeout 30 "$container_id"
docker rm "$container_id" docker rm "$container_id"
"${compose[@]}" up -d --no-deps --scale "worker=$LOAD_WORKER_REPLICAS" worker "${compose[@]}" up -d --no-deps --scale "worker=$LOAD_WORKER_REPLICAS" worker
} >>"$output_dir/worker-replacements.txt" } >>"$output_dir/worker-replacements.txt"
@ -438,8 +479,8 @@ probe_pid=
jq -s '{ jq -s '{
samples: length, samples: length,
failures: (map(select(.status != "200")) | length), failures: (map(select(.status != "200" or .metrics_status != "200")) | length),
nodes: (map(.body | fromjson? | .node) | map(select(. != null)) | unique) nodes: (map(.node) | map(select(. != null and . != "")) | unique)
}' "$probe_log" >"$output_dir/readiness-summary.json" }' "$probe_log" >"$output_dir/readiness-summary.json"
if ! jq -e '.samples > 0 and .failures == 0 and (.nodes | length) >= 2' \ 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" "$ROOT/deploy/helm/who-need-help"
echo "Scanning only tracked and non-ignored source files" 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 --null --no-recursion --files-from="$scan_list" --create --file="$scan_tar"
tar --extract --file="$scan_tar" --directory "$scan_dir" tar --extract --file="$scan_tar" --directory "$scan_dir"
"$ROOT/.tools/bin/helm" template who-need-help \ "$ROOT/.tools/bin/helm" template who-need-help \

View File

@ -4,7 +4,7 @@ defmodule WhoNeedHelp.AccountsTest do
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.Accounts.{User, UserToken} alias WhoNeedHelp.Accounts.{SocialIdentity, User, UserToken}
describe "get_user_by_email/1" do describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" 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 test "returns the user if the email exists" do
%{id: id} = user = user_fixture() %{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(user.email)
assert %User{id: ^id} = Accounts.get_user_by_email(" #{String.upcase(user.email)} ")
end end
end end
@ -32,6 +33,12 @@ defmodule WhoNeedHelp.AccountsTest do
assert %User{id: ^id} = assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password()) 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 end
test "does not authenticate a suspended user with a valid password" do test "does not authenticate a suspended user with a valid password" do
@ -140,6 +147,67 @@ defmodule WhoNeedHelp.AccountsTest do
end end
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 describe "change_user_email/3" do
test "returns a user changeset" do test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
@ -364,6 +432,10 @@ defmodule WhoNeedHelp.AccountsTest do
end end
describe "login_user_by_magic_link/1" do 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 test "confirms user and expires tokens" do
user = unconfirmed_user_fixture() user = unconfirmed_user_fixture()
refute user.confirmed_at refute user.confirmed_at
@ -384,6 +456,24 @@ defmodule WhoNeedHelp.AccountsTest do
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token) assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end 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 test "rejects a suspended user and consumes the magic link" do
user = user_fixture() user = user_fixture()
@ -419,6 +509,54 @@ defmodule WhoNeedHelp.AccountsTest do
end end
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 describe "get_user_by_session_token/1 moderation boundary" do
test "never authenticates a suspended user even if a token still exists" do test "never authenticates a suspended user even if a token still exists" do
user = user_fixture() user = user_fixture()

View File

@ -88,12 +88,113 @@ defmodule WhoNeedHelp.ActivitiesTest do
Activities.request_to_join(context.outsider_scope, activity.id) Activities.request_to_join(context.outsider_scope, activity.id)
end 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} = assert {:error, :not_found} =
Activities.approve_participant(context.organizer_scope, "not-a-uuid") Activities.approve_participant(context.organizer_scope, "not-a-uuid")
assert {:error, :not_found} = assert {:error, :not_found} =
Activities.decline_participant(context.organizer_scope, "not-a-uuid") 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 end
test "unapproved viewers receive no private chat or pending participant data", context do 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") assert {:error, :not_found} = Catalog.unvote(context.requester_scope, "not-a-uuid")
end 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 test "chat is durable and only visible to match participants", context do
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture() outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
@ -211,6 +266,21 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert oldest.next_cursor == nil assert oldest.next_cursor == nil
end 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 test "stopping tracking deletes the exact current position", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
@ -229,6 +299,21 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
refute Repo.get(Position, position.id) refute Repo.get(Position, position.id)
end 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 test "concurrent tracking stops serialize and broadcast once", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {: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") assert {:error, :not_found} = Trust.unblock(context.requester_scope, "not-a-uuid")
end 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 test "forged moderation identifiers return not found without crashing", context do
moderator = moderator =
user_fixture(display_name: "Moderator") user_fixture(display_name: "Moderator")
@ -157,6 +168,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert entry.unique_people == 1 assert entry.unique_people == 1
assert entry.verified_people == 1 assert entry.verified_people == 1
assert entry.location_supported_people == 1 assert entry.location_supported_people == 1
assert is_nil(entry.user.email)
assert is_nil(entry.user.hashed_password)
assert Repo.exists?( assert Repo.exists?(
from signal in AbuseSignal, from signal in AbuseSignal,
@ -240,6 +253,14 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert {:error, changeset} = Help.create_request(context.requester_scope, invalid) assert {:error, changeset} = Help.create_request(context.requester_scope, invalid)
assert "pickup_status has an invalid value" in errors_on(changeset).structured_data 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 end
test "help creation rejects missing safety consent and non-help categories", context do test "help creation rejects missing safety consent and non-help categories", context do
@ -284,6 +305,15 @@ defmodule WhoNeedHelp.TrustSafetyTest do
) )
end 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 test "restricted accounts cannot perform trust-sensitive actions", context do
context.helper context.helper
|> WhoNeedHelp.Accounts.User.moderation_changeset(%{moderation_status: :restricted}) |> WhoNeedHelp.Accounts.User.moderation_changeset(%{moderation_status: :restricted})
@ -293,14 +323,16 @@ defmodule WhoNeedHelp.TrustSafetyTest do
Help.create_request(context.helper_scope, context.attrs) Help.create_request(context.helper_scope, context.attrs)
end 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") hidden_attrs = Map.put(context.attrs, "location_visibility", "hidden")
{:ok, request} = Help.create_request(context.requester_scope, hidden_attrs) {: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) request = Help.get_request!(request.id)
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture() outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
assert is_nil(WhoNeedHelp.Help.HelpRequest.public_coordinates(request)) assert is_nil(WhoNeedHelp.Help.HelpRequest.public_coordinates(request))
assert is_nil(Help.request_coordinates(outsider_scope, 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} = assert %{latitude: 50.4501, longitude: 30.5234, exact: true} =
Help.request_coordinates(context.requester_scope, request) 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, "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 =~ "# TYPE who_need_help_http_requests_total counter"
assert body =~ "who_need_help_http_requests_total " assert body =~ "who_need_help_http_requests_total "
assert body =~ "who_need_help_http_request_duration_microseconds_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 =~ "frame-ancestors 'none'"
assert content_security_policy =~ "https://tile.openstreetmap.org" assert content_security_policy =~ "https://tile.openstreetmap.org"
assert get_resp_header(conn, "permissions-policy") == [
"geolocation=(self), camera=(), microphone=(), payment=(), usb=()"
]
assert html =~ assert html =~
~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png") ~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png")
@ -34,6 +38,22 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
) == 1 ) == 1
end 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 test "GET / selects Russian locale", %{conn: conn} do
conn = get(conn, ~p"/?locale=ru") conn = get(conn, ~p"/?locale=ru")
assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется." assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется."

View File

@ -2,11 +2,13 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
use WhoNeedHelpWeb.ConnCase, async: true use WhoNeedHelpWeb.ConnCase, async: true
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions
describe "GET /users/register" do describe "GET /users/register" do
test "renders registration page", %{conn: conn} do test "renders registration page", %{conn: conn} do
conn = get(conn, ~p"/users/register") conn = get(conn, ~p"/users/register")
response = html_response(conn, 200) response = html_response(conn, 200)
assert get_resp_header(conn, "cache-control") == ["no-store"]
assert response =~ "Register" assert response =~ "Register"
assert response =~ ~p"/users/log-in" assert response =~ ~p"/users/log-in"
assert response =~ ~p"/users/register" assert response =~ ~p"/users/register"
@ -20,6 +22,28 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
end end
describe "POST /users/register" do 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 @tag :capture_log
test "creates account but does not log in", %{conn: conn} do test "creates account but does not log in", %{conn: conn} do
email = unique_user_email() email = unique_user_email()
@ -33,7 +57,25 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
assert redirected_to(conn) == ~p"/users/log-in" assert redirected_to(conn) == ~p"/users/log-in"
assert conn.assigns.flash["info"] =~ 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 end
test "render errors for invalid data", %{conn: conn} do test "render errors for invalid data", %{conn: conn} do

View File

@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
use WhoNeedHelpWeb.ConnCase, async: false use WhoNeedHelpWeb.ConnCase, async: false
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
setup do setup do
@ -12,9 +13,12 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
test "renders login page", %{conn: conn} do test "renders login page", %{conn: conn} do
conn = get(conn, ~p"/users/log-in") conn = get(conn, ~p"/users/log-in")
response = html_response(conn, 200) response = html_response(conn, 200)
assert get_resp_header(conn, "cache-control") == ["no-store"]
assert response =~ "Log in" assert response =~ "Log in"
assert response =~ ~p"/users/register" assert response =~ ~p"/users/register"
assert response =~ "Log in with email" assert response =~ "Log in with email"
assert response =~ ~s(id="magic-link-fragment-form")
assert response =~ ~s(hidden)
end end
test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do 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
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 describe "POST /users/log-in - email and password" do
test "logs the user in", %{conn: conn, user: user} do test "logs the user in", %{conn: conn, user: user} do
user = set_password(user) user = set_password(user)
@ -189,7 +161,16 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
end end
describe "POST /users/log-in - magic link" do 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 test "sends magic link email when user exists", %{conn: conn, user: user} do
assert_email_sent()
conn = conn =
post(conn, ~p"/users/log-in", %{ post(conn, ~p"/users/log-in", %{
"user" => %{"email" => user.email} "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 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 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 end
test "does not send a magic link for a suspended user", %{conn: conn, user: user} do 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 alias WhoNeedHelp.Accounts
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions
setup :register_and_log_in_user setup :register_and_log_in_user
@ -70,18 +71,29 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert response =~ "Settings" assert response =~ "Settings"
assert response =~ "should be at least 12 character(s)" assert response =~ "should be at least 12 character(s)"
assert response =~ "does not match password" 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) assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token)
end end
end end
describe "PUT /users/settings (change email form)" do 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 @tag :capture_log
test "updates the user email", %{conn: conn, user: user} do test "updates the user email", %{conn: conn, user: user} do
assert_email_sent()
changed_email = unique_user_email()
conn = conn =
put(conn, ~p"/users/settings", %{ put(conn, ~p"/users/settings", %{
"action" => "update_email", "action" => "update_email",
"user" => %{"email" => unique_user_email()} "user" => %{"email" => changed_email}
}) })
assert redirected_to(conn) == ~p"/users/settings" assert redirected_to(conn) == ~p"/users/settings"
@ -89,6 +101,11 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"A link to confirm your email" "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) assert Accounts.get_user_by_email(user.email)
end end
@ -103,9 +120,29 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert response =~ "Settings" assert response =~ "Settings"
assert response =~ "must have the @ sign and no spaces" assert response =~ "must have the @ sign and no spaces"
end 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 end
describe "GET /users/settings/confirm-email/:token" do describe "POST /users/settings/confirm-email" do
setup %{user: user} do setup %{user: user} do
email = unique_user_email() email = unique_user_email()
@ -118,7 +155,12 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
end end
test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do 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 redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
@ -127,7 +169,7 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
refute Accounts.get_user_by_email(user.email) refute Accounts.get_user_by_email(user.email)
assert Accounts.get_user_by_email(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" assert redirected_to(conn) == ~p"/users/settings"
@ -136,7 +178,31 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
end end
test "does not update email with invalid token", %{conn: conn, user: user} do 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 redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ 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 test "redirects if user is not logged in", %{token: token} do
conn = build_conn() 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" assert redirected_to(conn) == ~p"/users/log-in"
end 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
end end

View File

@ -23,6 +23,32 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Створити термінову заявку" assert html =~ "Створити термінову заявку"
end 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 test "invalid request and activity identifiers redirect without crashing", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/requests"}}} = assert {:error, {:live_redirect, %{to: "/requests"}}} =
live(conn, "/requests/not-a-uuid") live(conn, "/requests/not-a-uuid")
@ -59,6 +85,38 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
refute render(view) =~ "Realtime medicine pickup" refute render(view) =~ "Realtime medicine pickup"
end 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 test "activity index applies PubSub updates without a full page reload" do
Catalog.seed_defaults() Catalog.seed_defaults()
organizer = user_fixture(display_name: "Realtime organizer") organizer = user_fixture(display_name: "Realtime organizer")
@ -89,6 +147,45 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert render(view) =~ "Realtime coffee meetup" assert render(view) =~ "Realtime coffee meetup"
end 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 test "new request form is driven by category structured fields", %{conn: conn} do
category = Catalog.seed_defaults() category = Catalog.seed_defaults()
{:ok, view, html} = live(conn, ~p"/requests/new") {:ok, view, html} = live(conn, ~p"/requests/new")
@ -259,6 +356,34 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "I will be there." assert html =~ "I will be there."
assert render(organizer_view) =~ "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(organizer_view)
stop_live_view(participant_view) stop_live_view(participant_view)
end end
@ -294,6 +419,35 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert_push_event(helper_view, "reset-message-form", %{id: "message-form"}) assert_push_event(helper_view, "reset-message-form", %{id: "message-form"})
assert render(requester_view) =~ "I am on my way to the pharmacy." 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( send(
requester_view.pid, requester_view.pid,
{:position_updated, helper.id, {:position_updated, helper.id,
@ -431,6 +585,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
} }
end 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{ defp stop_live_view(%Phoenix.LiveViewTest.View{
proxy: {_ref, _topic, proxy_pid} proxy: {_ref, _topic, proxy_pid}
}) do }) do