fix: harden security concurrency and client boundaries
This commit is contained in:
parent
05d06e059a
commit
2358772d25
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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
6
.gitignore
vendored
|
|
@ -67,3 +67,9 @@ __pycache__/
|
||||||
*.aab
|
*.aab
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
/.direnv/
|
||||||
|
/.envrc
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,17 +74,21 @@ 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(
|
||||||
device.hasObject(By.text(context.getString(R.string.tracking_active)))
|
device.hasObject(By.text(context.getString(R.string.tracking_active)))
|
||||||
|
|
@ -99,7 +107,34 @@ 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;
|
||||||
|
|
||||||
|
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(
|
Until.findObject(
|
||||||
By.res(
|
By.res(
|
||||||
LOCATION_PERMISSION_CONTROLLER,
|
LOCATION_PERMISSION_CONTROLLER,
|
||||||
|
|
@ -108,8 +143,9 @@ public final class AndroidClientInstrumentedTest {
|
||||||
),
|
),
|
||||||
UI_TIMEOUT_MS
|
UI_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (allow == null) {
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,12 +480,14 @@ 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 =
|
||||||
case Repo.one(query) do
|
Repo.transact(fn ->
|
||||||
|
case query |> lock("FOR UPDATE") |> Repo.one() do
|
||||||
{%User{moderation_status: :suspended}, token} ->
|
{%User{moderation_status: :suspended}, token} ->
|
||||||
Repo.delete!(token)
|
with {:ok, _token} <- Repo.delete(token) do
|
||||||
{:error, :not_found}
|
{:ok, {:rejected, :not_found}}
|
||||||
|
end
|
||||||
|
|
||||||
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
|
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
|
||||||
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
|
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
|
||||||
|
|
@ -500,15 +502,25 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
{%User{confirmed_at: nil} = user, _token} ->
|
{%User{confirmed_at: nil} = user, _token} ->
|
||||||
user
|
user
|
||||||
|> User.confirm_changeset()
|
|> User.confirm_changeset()
|
||||||
|> update_user_and_delete_all_tokens()
|
|> update_user_and_delete_all_tokens_in_transaction()
|
||||||
|
|
||||||
{user, token} ->
|
{user, token} ->
|
||||||
Repo.delete!(token)
|
with {:ok, _token} <- Repo.delete(token) do
|
||||||
{:ok, {user, []}}
|
{:ok, {user, []}}
|
||||||
|
end
|
||||||
|
|
||||||
nil ->
|
nil ->
|
||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
end
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, {:rejected, reason}} -> {:error, reason}
|
||||||
|
other -> other
|
||||||
|
end
|
||||||
|
else
|
||||||
|
_invalid_token -> {:error, :not_found}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc ~S"""
|
@doc ~S"""
|
||||||
|
|
@ -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,7 +585,10 @@ 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)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_user_and_delete_all_tokens_in_transaction(changeset) do
|
||||||
with {:ok, user} <- Repo.update(changeset) do
|
with {:ok, user} <- Repo.update(changeset) do
|
||||||
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
|
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
|
||||||
|
|
||||||
|
|
@ -575,6 +596,5 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
|
|
||||||
{:ok, {user, tokens_to_expire}}
|
{:ok, {user, tokens_to_expire}}
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,19 +233,18 @@ 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
|
cond do
|
||||||
|
|
@ -265,6 +266,9 @@ defmodule WhoNeedHelp.Activities do
|
||||||
true ->
|
true ->
|
||||||
upsert_join_request(activity.id, user.id, now)
|
upsert_join_request(activity.id, user.id, now)
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -275,8 +279,8 @@ 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
|
||||||
|
|
@ -314,6 +318,9 @@ defmodule WhoNeedHelp.Activities do
|
||||||
{:ok, participant}
|
{:ok, participant}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -328,6 +335,7 @@ defmodule WhoNeedHelp.Activities do
|
||||||
|
|
||||||
def leave_activity(%Scope{user: user}, activity_id) do
|
def leave_activity(%Scope{user: user}, activity_id) do
|
||||||
result =
|
result =
|
||||||
|
with {:ok, activity_id} <- cast_id(activity_id) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
participant =
|
participant =
|
||||||
Participant
|
Participant
|
||||||
|
|
@ -362,6 +370,7 @@ defmodule WhoNeedHelp.Activities do
|
||||||
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,9 +385,10 @@ 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
|
||||||
|
|
@ -400,6 +410,9 @@ defmodule WhoNeedHelp.Activities do
|
||||||
})
|
})
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -514,9 +527,8 @@ 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
|
cond do
|
||||||
activity.creator_id != organizer_id ->
|
activity.creator_id != organizer_id ->
|
||||||
{:error, :forbidden}
|
{:error, :forbidden}
|
||||||
|
|
@ -543,6 +555,9 @@ defmodule WhoNeedHelp.Activities do
|
||||||
{:ok, participant}
|
{:ok, participant}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
after_participant_change(result, participant_activity_id(result), :participant_declined)
|
after_participant_change(result, participant_activity_id(result), :participant_declined)
|
||||||
|
|
@ -550,9 +565,9 @@ defmodule WhoNeedHelp.Activities do
|
||||||
|
|
||||||
defp transition_activity(user_id, activity_id, target_status) do
|
defp transition_activity(user_id, activity_id, target_status) do
|
||||||
result =
|
result =
|
||||||
|
with {:ok, activity_id} <- cast_id(activity_id) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
activity = locked_activity(activity_id)
|
with %Activity{} = activity <- locked_activity(activity_id) do
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
activity.creator_id != user_id ->
|
activity.creator_id != user_id ->
|
||||||
{:error, :forbidden}
|
{:error, :forbidden}
|
||||||
|
|
@ -569,7 +584,8 @@ defmodule WhoNeedHelp.Activities do
|
||||||
:completed -> %{status: :completed, completed_at: timestamp}
|
:completed -> %{status: :completed, completed_at: timestamp}
|
||||||
end
|
end
|
||||||
|
|
||||||
with {:ok, activity} <- activity |> Ecto.Changeset.change(attrs) |> Repo.update(),
|
with {:ok, activity} <-
|
||||||
|
activity |> Ecto.Changeset.change(attrs) |> Repo.update(),
|
||||||
{:ok, _audit} <-
|
{:ok, _audit} <-
|
||||||
Trust.audit(
|
Trust.audit(
|
||||||
user_id,
|
user_id,
|
||||||
|
|
@ -580,7 +596,11 @@ defmodule WhoNeedHelp.Activities do
|
||||||
{:ok, activity}
|
{:ok, activity}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
end
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -200,18 +200,20 @@ defmodule WhoNeedHelp.Help do
|
||||||
end
|
end
|
||||||
|
|
||||||
def accept_request(%Scope{user: helper}, request_id) do
|
def accept_request(%Scope{user: helper}, request_id) do
|
||||||
|
result =
|
||||||
|
with {:ok, request_id} <- cast_id(request_id),
|
||||||
|
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) do
|
||||||
now = DateTime.utc_now(:second)
|
now = DateTime.utc_now(:second)
|
||||||
code = handover_code(request_id)
|
code = handover_code(request_id)
|
||||||
|
|
||||||
result =
|
|
||||||
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_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 do
|
||||||
:ok = Trust.lock_user_pair(request.requester_id, helper.id)
|
:ok = Trust.lock_user_pair(request.requester_id, helper.id)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
|
|
@ -255,7 +257,12 @@ defmodule WhoNeedHelp.Help do
|
||||||
{:ok, assignment}
|
{:ok, assignment}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
else
|
||||||
|
:error -> {:error, :not_found}
|
||||||
end
|
end
|
||||||
|
|
||||||
case result do
|
case result do
|
||||||
|
|
@ -283,9 +290,11 @@ 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)
|
||||||
|
|
||||||
|
|
@ -311,21 +320,32 @@ defmodule WhoNeedHelp.Help do
|
||||||
|> 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
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
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
|
||||||
|
is_nil(request) ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
request.requester_id == user.id and request.status in [:open, :matched] ->
|
||||||
now = DateTime.utc_now(:second)
|
now = DateTime.utc_now(:second)
|
||||||
|
|
||||||
assignment =
|
assignment =
|
||||||
|
|
@ -343,10 +363,13 @@ defmodule WhoNeedHelp.Help do
|
||||||
Trust.audit(user.id, "request.cancelled", "request", request.id) do
|
Trust.audit(user.id, "request.cancelled", "request", request.id) do
|
||||||
{:ok, request}
|
{:ok, request}
|
||||||
end
|
end
|
||||||
else
|
|
||||||
|
true ->
|
||||||
{:error, :forbidden}
|
{:error, :forbidden}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
else
|
||||||
|
:error -> {:error, :not_found}
|
||||||
end
|
end
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, request} ->
|
{:ok, request} ->
|
||||||
|
|
@ -361,12 +384,14 @@ 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
|
||||||
|
assignment.status in [:accepted, :in_progress] do
|
||||||
now = DateTime.utc_now(:second)
|
now = DateTime.utc_now(:second)
|
||||||
|
|
||||||
with {:ok, assignment} <-
|
with {:ok, assignment} <-
|
||||||
|
|
@ -386,7 +411,12 @@ defmodule WhoNeedHelp.Help do
|
||||||
else
|
else
|
||||||
{:error, :invalid_transition}
|
{:error, :invalid_transition}
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
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,9 +520,10 @@ 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)
|
||||||
|
|
@ -509,9 +540,13 @@ defmodule WhoNeedHelp.Help do
|
||||||
{: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",
|
||||||
|
"assignment",
|
||||||
|
assignment.id,
|
||||||
|
%{"request_id" => request.id}
|
||||||
|
) do
|
||||||
{:ok, assignment}
|
{:ok, assignment}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -536,7 +571,12 @@ defmodule WhoNeedHelp.Help do
|
||||||
{:error, :invalid_transition}
|
{:error, :invalid_transition}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,10 @@ 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)
|
|
||||||
|
|
||||||
|
if current do
|
||||||
|
current = Repo.preload(current, :request)
|
||||||
request = current.request
|
request = current.request
|
||||||
recipient_id = counterpart_id(user.id, current, request)
|
recipient_id = counterpart_id(user.id, current, request)
|
||||||
|
|
||||||
|
|
@ -79,6 +80,9 @@ defmodule WhoNeedHelp.Messaging do
|
||||||
false -> {:error, :forbidden}
|
false -> {:error, :forbidden}
|
||||||
other -> other
|
other -> other
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
with {:ok, message} <- result do
|
with {:ok, message} <- result do
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,9 @@ 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()
|
||||||
|
|
||||||
|
if current do
|
||||||
request = Repo.get!(HelpRequest, current.request_id)
|
request = Repo.get!(HelpRequest, current.request_id)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
|
|
@ -95,6 +96,9 @@ defmodule WhoNeedHelp.Trust do
|
||||||
other -> other
|
other -> other
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
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,15 +1063,18 @@ 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
|
||||||
|
with {:ok, request_id} <- cast_id(request_id) do
|
||||||
case Repo.get(HelpRequest, request_id) do
|
case Repo.get(HelpRequest, request_id) do
|
||||||
%HelpRequest{requester_id: requester_id} when requester_id != user.id -> :ok
|
%HelpRequest{requester_id: requester_id} when requester_id != user.id -> :ok
|
||||||
%HelpRequest{} -> {:error, :cannot_report_self}
|
%HelpRequest{} -> {:error, :cannot_report_self}
|
||||||
nil -> {:error, :not_found}
|
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
|
||||||
|
with {:ok, assignment_id} <- cast_id(assignment_id) do
|
||||||
case Repo.get(Assignment, assignment_id) do
|
case Repo.get(Assignment, assignment_id) do
|
||||||
%Assignment{} = assignment ->
|
%Assignment{} = assignment ->
|
||||||
if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden}
|
if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden}
|
||||||
|
|
@ -1076,8 +1083,10 @@ defmodule WhoNeedHelp.Trust do
|
||||||
{: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
|
||||||
|
with {:ok, message_id} <- cast_id(message_id) do
|
||||||
case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do
|
case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do
|
||||||
%Message{sender_id: sender_id, assignment: assignment} ->
|
%Message{sender_id: sender_id, assignment: assignment} ->
|
||||||
cond do
|
cond do
|
||||||
|
|
@ -1090,9 +1099,11 @@ defmodule WhoNeedHelp.Trust do
|
||||||
{: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
|
||||||
|
with {:ok, activity_id} <- cast_id(activity_id) do
|
||||||
case Repo.get(Activity, activity_id) do
|
case Repo.get(Activity, activity_id) do
|
||||||
%Activity{creator_id: creator_id} when creator_id == user.id ->
|
%Activity{creator_id: creator_id} when creator_id == user.id ->
|
||||||
{:error, :cannot_report_self}
|
{:error, :cannot_report_self}
|
||||||
|
|
@ -1106,12 +1117,14 @@ defmodule WhoNeedHelp.Trust do
|
||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp authorize_report_target(
|
defp authorize_report_target(
|
||||||
%Scope{user: user},
|
%Scope{user: user},
|
||||||
%{"activity_message_id" => message_id}
|
%{"activity_message_id" => message_id}
|
||||||
)
|
)
|
||||||
when is_binary(message_id) do
|
when is_binary(message_id) do
|
||||||
|
with {:ok, message_id} <- cast_id(message_id) do
|
||||||
case Repo.get(ActivityMessage, message_id) do
|
case Repo.get(ActivityMessage, message_id) do
|
||||||
%ActivityMessage{sender_id: sender_id} when sender_id == user.id ->
|
%ActivityMessage{sender_id: sender_id} when sender_id == user.id ->
|
||||||
{:error, :cannot_report_self}
|
{:error, :cannot_report_self}
|
||||||
|
|
@ -1125,6 +1138,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp authorize_report_target(_scope, _attrs), do: {:error, :invalid_target}
|
defp authorize_report_target(_scope, _attrs), do: {:error, :invalid_target}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,45 @@
|
||||||
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} ->
|
||||||
|
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)
|
render(conn, :new, changeset: changeset)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
else
|
else
|
||||||
{:error, :rate_limited} ->
|
{:error, :rate_limited} ->
|
||||||
conn
|
conn
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
form = Phoenix.Component.to_form(%{"token" => token}, as: "user")
|
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> assign(:user, user)
|
|> put_status(:bad_request)
|
||||||
|> assign(:form, form)
|
|> put_flash(:error, gettext("The sign-in form is invalid."))
|
||||||
|> render(:confirm)
|
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -30,6 +30,39 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<.form
|
||||||
|
for={@magic_link_form}
|
||||||
|
id="magic-link-fragment-form"
|
||||||
|
action={~p"/users/log-in?_action=confirmed"}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="magic-link-fragment-token"
|
||||||
|
type="hidden"
|
||||||
|
name={@magic_link_form[:token].name}
|
||||||
|
value=""
|
||||||
|
/>
|
||||||
|
<p class="alert alert-info mb-4">
|
||||||
|
{gettext("Your secure email link is ready. Continue to sign in.")}
|
||||||
|
</p>
|
||||||
|
<.button
|
||||||
|
id="remember-login-button"
|
||||||
|
name={@magic_link_form[:remember_me].name}
|
||||||
|
value="true"
|
||||||
|
phx-disable-with={gettext("Confirming...")}
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{gettext("Confirm and stay logged in")}
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
phx-disable-with={gettext("Confirming...")}
|
||||||
|
class="btn btn-primary btn-soft w-full mt-2"
|
||||||
|
>
|
||||||
|
{gettext("Confirm and log in only this time")}
|
||||||
|
</.button>
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
<div id="standard-login-options">
|
||||||
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
|
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
|
||||||
<.input
|
<.input
|
||||||
readonly={!!@current_scope}
|
readonly={!!@current_scope}
|
||||||
|
|
@ -73,4 +106,5 @@
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
case RateLimiter.check(:email_change_email, new_email) do
|
||||||
|
{:ok, _limit} ->
|
||||||
|
deliver_email_change_instructions(conn, user, changeset)
|
||||||
|
|
||||||
|
{:error, :rate_limited} ->
|
||||||
conn
|
conn
|
||||||
|
|> put_status(:too_many_requests)
|
||||||
|> put_flash(
|
|> put_flash(
|
||||||
:info,
|
:error,
|
||||||
gettext("A link to confirm your email change has been sent to the new address.")
|
gettext("Too many email-change requests in the configured time window.")
|
||||||
)
|
)
|
||||||
|> redirect(to: ~p"/users/settings")
|
|> 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
entries =
|
||||||
case order do
|
case order do
|
||||||
:open -> Enum.sort_by(entries, &{&1.expires_at, &1.id}, :asc)
|
:open -> Enum.sort_by(entries, &{&1.expires_at, &1.id}, :asc)
|
||||||
:mine -> Enum.sort_by(entries, &{&1.inserted_at, &1.id}, :desc)
|
:mine -> Enum.sort_by(entries, &{&1.inserted_at, &1.id}, :desc)
|
||||||
end
|
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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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-адресою"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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' \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 "
|
||||||
|
|
|
||||||
|
|
@ -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) =~ "Помощь может быть ближе, чем кажется."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user