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/cache_manifest.json
|
||||
/android/.gradle/
|
||||
/android/.kotlin/
|
||||
/android/.idea/
|
||||
/android/.cxx/
|
||||
/android/app/build/
|
||||
/android/build/
|
||||
/android/dist/
|
||||
/android/dist-*/
|
||||
/android/local.properties
|
||||
/android/*.apk
|
||||
/android/*.aab
|
||||
/android/*.jks
|
||||
/android/*.keystore
|
||||
/android/**/*.apk
|
||||
/android/**/*.aab
|
||||
/android/**/*.jks
|
||||
/android/**/*.keystore
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
/.direnv/
|
||||
/.envrc
|
||||
/.tools/
|
||||
/.playwright-cli/
|
||||
/output/
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ WNH_BASE_URL=
|
|||
# production capacity recommendations.
|
||||
WNH_TRACKING_MIN_TIME_MS=5000
|
||||
WNH_TRACKING_HTTP_TIMEOUT_MS=15000
|
||||
WNH_ANDROID_TEST_API_MATRIX=30 34 37.0
|
||||
WNH_ANDROID_TEST_DATA_PARTITION_SIZE=4G
|
||||
WNH_ANDROID_TEST_API_MATRIX="24 30 34 37.0"
|
||||
WNH_ANDROID_TEST_DATA_PARTITION_SIZE=1G
|
||||
# Public raster tile template used by MapLibre. Use a provider whose policy and
|
||||
# capacity match the deployment before a public launch.
|
||||
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -67,3 +67,9 @@ __pycache__/
|
|||
*.aab
|
||||
*.jks
|
||||
*.keystore
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
/.direnv/
|
||||
/.envrc
|
||||
|
|
|
|||
|
|
@ -315,7 +315,7 @@ is not recreated.
|
|||
The Android device suite builds dedicated debug and instrumentation APKs,
|
||||
boots a fresh emulator in an isolated container without external networking,
|
||||
and serves its HTTP fixture only on device loopback. Run the default API 37
|
||||
probe or the complete API 30/34/37 matrix:
|
||||
probe or the complete minimum/current API 24/30/34/37 matrix:
|
||||
|
||||
```bash
|
||||
./scripts/android-instrumentation-test.sh
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
.gradle
|
||||
.kotlin
|
||||
.idea
|
||||
.cxx
|
||||
app/build
|
||||
build
|
||||
dist
|
||||
dist-*
|
||||
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
|
||||
background-location permission.
|
||||
|
||||
The native tracking bridge uses `WebViewCompat.addWebMessageListener` with the
|
||||
exact configured origin and rejects messages outside the main frame. It does
|
||||
not expose a legacy `addJavascriptInterface` object to every frame.
|
||||
|
||||
## Verified build configuration
|
||||
|
||||
- Android Gradle Plugin 9.3.0
|
||||
- Gradle 9.6.1
|
||||
- Android SDK Command-line Tools 22.0
|
||||
- Android CLI 1.0.15857036 (embedded in the locked Command-line Tools archive)
|
||||
- AndroidX WebKit 1.16.0
|
||||
- compileSdk / targetSdk 37
|
||||
- Build Tools 37.0.0
|
||||
- Java source and bytecode level 17
|
||||
|
|
@ -88,7 +93,7 @@ report.
|
|||
|
||||
## Automated device tests
|
||||
|
||||
Run the default API 37 suite or the complete API 30/34/37 matrix from the
|
||||
Run the default API 37 suite or the complete API 24/30/34/37 matrix from the
|
||||
repository root:
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ fun manifestOrigin(value: String): URI? =
|
|||
(uri.scheme == "http" || uri.scheme == "https") && !uri.host.isNullOrBlank()
|
||||
}
|
||||
|
||||
fun isOriginPath(uri: URI): Boolean =
|
||||
uri.path.isNullOrEmpty() || uri.path == "/"
|
||||
|
||||
val debugManifestOrigin = manifestOrigin(debugBaseUrl.get())
|
||||
val releaseManifestOrigin = manifestOrigin(releaseBaseUrl.get())
|
||||
|
||||
|
|
@ -119,6 +122,7 @@ tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.
|
|||
uri.scheme != "https" ||
|
||||
uri.host.isNullOrBlank() ||
|
||||
uri.userInfo != null ||
|
||||
!isOriginPath(uri) ||
|
||||
uri.query != null ||
|
||||
uri.fragment != null
|
||||
) {
|
||||
|
|
@ -142,6 +146,7 @@ tasks.matching { it.name == "preDebugBuild" }.configureEach {
|
|||
(uri.scheme != "http" && uri.scheme != "https") ||
|
||||
uri.host.isNullOrBlank() ||
|
||||
uri.userInfo != null ||
|
||||
!isOriginPath(uri) ||
|
||||
uri.query != null ||
|
||||
uri.fragment != null
|
||||
) {
|
||||
|
|
@ -177,6 +182,7 @@ tasks.withType<JavaCompile>().configureEach {
|
|||
|
||||
dependencies {
|
||||
implementation("androidx.activity:activity:1.13.0")
|
||||
implementation("androidx.webkit:webkit:1.16.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test:core:1.7.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
|
|
|
|||
|
|
@ -11,11 +11,15 @@ import static org.junit.Assert.assertNotNull;
|
|||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.SystemClock;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
|
|
@ -70,16 +74,20 @@ public final class AndroidClientInstrumentedTest {
|
|||
assertFalse(hasLocationPermission());
|
||||
|
||||
try (ActivityScenario<MainActivity> scenario = launch("/permission-boundary")) {
|
||||
scenario.onActivity(activity ->
|
||||
activity.startForegroundService(
|
||||
TrackingService.startIntent(
|
||||
activity,
|
||||
UUID.randomUUID().toString(),
|
||||
"csrf-without-permission",
|
||||
"_session=without-permission"
|
||||
)
|
||||
)
|
||||
);
|
||||
scenario.onActivity(activity -> {
|
||||
Intent intent = TrackingService.startIntent(
|
||||
activity,
|
||||
UUID.randomUUID().toString(),
|
||||
"csrf-without-permission",
|
||||
"_session=without-permission"
|
||||
);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
activity.startForegroundService(intent);
|
||||
} else {
|
||||
activity.startService(intent);
|
||||
}
|
||||
});
|
||||
|
||||
waitForServiceState(false);
|
||||
assertFalse(
|
||||
|
|
@ -99,17 +107,45 @@ public final class AndroidClientInstrumentedTest {
|
|||
.withElement(findElement(Locator.ID, "location"))
|
||||
.perform(webClick());
|
||||
|
||||
UiObject2 allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
LOCATION_PERMISSION_CONTROLLER,
|
||||
"permission_allow_foreground_only_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
UiObject2 allow = null;
|
||||
|
||||
if (allow == null) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
"com.android.packageinstaller",
|
||||
"permission_allow_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (allow == null && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
"com.google.android.packageinstaller",
|
||||
"permission_allow_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
LOCATION_PERMISSION_CONTROLLER,
|
||||
"permission_allow_foreground_only_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(By.textContains("While using the app")),
|
||||
UI_TIMEOUT_MS
|
||||
|
|
@ -328,16 +364,20 @@ public final class AndroidClientInstrumentedTest {
|
|||
ActivityScenario<MainActivity> scenario,
|
||||
String assignmentId
|
||||
) {
|
||||
scenario.onActivity(activity ->
|
||||
activity.startForegroundService(
|
||||
TrackingService.startIntent(
|
||||
activity,
|
||||
assignmentId,
|
||||
"instrumentation-csrf",
|
||||
"_session=instrumentation"
|
||||
)
|
||||
)
|
||||
);
|
||||
scenario.onActivity(activity -> {
|
||||
Intent intent = TrackingService.startIntent(
|
||||
activity,
|
||||
assignmentId,
|
||||
"instrumentation-csrf",
|
||||
"_session=instrumentation"
|
||||
);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
activity.startForegroundService(intent);
|
||||
} else {
|
||||
activity.startService(intent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openNotificationAndClickStop() {
|
||||
|
|
@ -347,14 +387,59 @@ public final class AndroidClientInstrumentedTest {
|
|||
UI_TIMEOUT_MS
|
||||
);
|
||||
assertNotNull("Foreground tracking notification was not visible", active);
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
sendStopActionFromActiveNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
UiObject2 stop = device.wait(
|
||||
Until.findObject(By.text(context.getString(R.string.tracking_stop_action))),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
|
||||
assertNotNull("Foreground tracking notification had no Stop action", stop);
|
||||
stop.click();
|
||||
}
|
||||
|
||||
private void sendStopActionFromActiveNotification() {
|
||||
NotificationManager manager = context.getSystemService(NotificationManager.class);
|
||||
String expectedTitle = context.getString(R.string.tracking_stop_action);
|
||||
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(UI_TIMEOUT_MS);
|
||||
|
||||
while (System.nanoTime() < deadline) {
|
||||
for (StatusBarNotification statusBarNotification : manager.getActiveNotifications()) {
|
||||
Notification.Action[] actions = statusBarNotification.getNotification().actions;
|
||||
|
||||
if (actions == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (Notification.Action action : actions) {
|
||||
if (
|
||||
action.title != null
|
||||
&& expectedTitle.contentEquals(action.title)
|
||||
&& action.actionIntent != null
|
||||
) {
|
||||
try {
|
||||
action.actionIntent.send();
|
||||
return;
|
||||
} catch (PendingIntent.CanceledException error) {
|
||||
throw new AssertionError(
|
||||
"Foreground tracking Stop action was canceled",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SystemClock.sleep(50);
|
||||
}
|
||||
|
||||
throw new AssertionError("Foreground tracking notification had no Stop action");
|
||||
}
|
||||
|
||||
private boolean hasLocationPermission() {
|
||||
return context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
|
|
|||
|
|
@ -39,15 +39,19 @@ public final class TrackingProcessDeathProbeTest {
|
|||
);
|
||||
}
|
||||
|
||||
context.startForegroundService(
|
||||
TrackingService.startIntent(
|
||||
context,
|
||||
UUID.randomUUID().toString(),
|
||||
"process-death-csrf",
|
||||
"_session=process-death"
|
||||
)
|
||||
Intent intent = TrackingService.startIntent(
|
||||
context,
|
||||
UUID.randomUUID().toString(),
|
||||
"process-death-csrf",
|
||||
"_session=process-death"
|
||||
);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent);
|
||||
} else {
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
// The host harness kills this exact process after observing the active
|
||||
// service and notification. Reaching the deadline means the harness did
|
||||
// not perform the required process-death probe.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import android.util.Log;
|
|||
import android.view.ViewGroup;
|
||||
import android.webkit.CookieManager;
|
||||
import android.webkit.GeolocationPermissions;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.SslErrorHandler;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebResourceError;
|
||||
|
|
@ -30,10 +29,17 @@ import androidx.activity.ComponentActivity;
|
|||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.webkit.WebMessageCompat;
|
||||
import androidx.webkit.WebViewCompat;
|
||||
import androidx.webkit.WebViewFeature;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public final class MainActivity extends ComponentActivity {
|
||||
private static final String LOG_TAG = "WhoNeedHelpWebView";
|
||||
private WebView webView;
|
||||
|
|
@ -106,7 +112,7 @@ public final class MainActivity extends ComponentActivity {
|
|||
cookieManager.setAcceptCookie(true);
|
||||
cookieManager.setAcceptThirdPartyCookies(webView, false);
|
||||
|
||||
webView.addJavascriptInterface(new NativeTrackingBridge(), "WhoNeedHelpAndroid");
|
||||
configureNativeTrackingBridge();
|
||||
webView.setWebViewClient(new TrustedWebViewClient());
|
||||
webView.setWebChromeClient(new LocationWebChromeClient());
|
||||
getOnBackPressedDispatcher().addCallback(
|
||||
|
|
@ -192,7 +198,6 @@ public final class MainActivity extends ComponentActivity {
|
|||
|
||||
if (webView != null) {
|
||||
webView.stopLoading();
|
||||
webView.removeJavascriptInterface("WhoNeedHelpAndroid");
|
||||
webView.setWebChromeClient(null);
|
||||
webView.setWebViewClient(null);
|
||||
webView.destroy();
|
||||
|
|
@ -275,6 +280,60 @@ public final class MainActivity extends ComponentActivity {
|
|||
nativeTrackingPermissionLauncher.launch(permissions.toArray(new String[0]));
|
||||
}
|
||||
|
||||
private void configureNativeTrackingBridge() {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
WebViewCompat.addWebMessageListener(
|
||||
webView,
|
||||
"WhoNeedHelpAndroid",
|
||||
Collections.singleton(trustedOrigin.originRule()),
|
||||
(
|
||||
WebView view,
|
||||
WebMessageCompat message,
|
||||
Uri sourceOrigin,
|
||||
boolean isMainFrame,
|
||||
androidx.webkit.JavaScriptReplyProxy replyProxy
|
||||
) -> {
|
||||
if (
|
||||
!isMainFrame
|
||||
|| sourceOrigin == null
|
||||
|| !trustedOrigin.matchesOrigin(sourceOrigin.toString())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleNativeTrackingMessage(message.getData());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private void handleNativeTrackingMessage(String payload) {
|
||||
if (payload == null) {
|
||||
dispatchNativeTrackingError();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject message = new JSONObject(payload);
|
||||
String action = message.optString("action", "");
|
||||
|
||||
if ("start".equals(action)) {
|
||||
prepareNativeTracking(
|
||||
message.optString("assignment_id", ""),
|
||||
message.optString("csrf_token", "")
|
||||
);
|
||||
} else if ("stop".equals(action)) {
|
||||
stopService(new Intent(MainActivity.this, TrackingService.class));
|
||||
} else {
|
||||
dispatchNativeTrackingError();
|
||||
}
|
||||
} catch (JSONException exception) {
|
||||
dispatchNativeTrackingError();
|
||||
}
|
||||
}
|
||||
|
||||
private void startPendingNativeTracking() {
|
||||
PendingNativeTracking pending = pendingNativeTracking;
|
||||
pendingNativeTracking = null;
|
||||
|
|
@ -519,20 +578,6 @@ public final class MainActivity extends ComponentActivity {
|
|||
}
|
||||
}
|
||||
|
||||
private final class NativeTrackingBridge {
|
||||
@JavascriptInterface
|
||||
public void startTracking(String assignmentId, String csrfToken) {
|
||||
runOnUiThread(() -> prepareNativeTracking(assignmentId, csrfToken));
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void stopTracking() {
|
||||
runOnUiThread(() ->
|
||||
stopService(new Intent(MainActivity.this, TrackingService.class))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PendingNativeTracking {
|
||||
private final String assignmentId;
|
||||
private final String csrfToken;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ final class TrustedOrigin {
|
|||
uri.getHost() == null ||
|
||||
uri.getHost().trim().isEmpty() ||
|
||||
uri.getUserInfo() != null ||
|
||||
!pathIsOrigin(uri.getPath()) ||
|
||||
uri.getQuery() != null ||
|
||||
uri.getFragment() != null
|
||||
) {
|
||||
|
|
@ -43,6 +44,22 @@ final class TrustedOrigin {
|
|||
return base.toString();
|
||||
}
|
||||
|
||||
String originRule() {
|
||||
try {
|
||||
return new URI(
|
||||
normalized(base.getScheme()),
|
||||
null,
|
||||
base.getHost(),
|
||||
base.getPort(),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).toString();
|
||||
} catch (URISyntaxException exception) {
|
||||
throw new IllegalStateException("Trusted origin could not be normalized", exception);
|
||||
}
|
||||
}
|
||||
|
||||
boolean matches(String value) {
|
||||
try {
|
||||
URI candidate = new URI(value);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package org.whoneedhelp.mobile;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ public final class TrustedOriginTest {
|
|||
assertFalse(origin.matches("https://help.example.evil.test/"));
|
||||
assertFalse(origin.matches("http://help.example/"));
|
||||
assertFalse(origin.matches("https://help.example:444/"));
|
||||
assertEquals("https://help.example", origin.originRule());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -25,6 +27,7 @@ public final class TrustedOriginTest {
|
|||
assertTrue(origin.matches("http://10.0.2.2:4010/requests"));
|
||||
assertTrue(origin.matchesOrigin("http://10.0.2.2:4010/"));
|
||||
assertFalse(origin.matchesOrigin("http://10.0.2.2:4010/requests"));
|
||||
assertEquals("http://10.0.2.2:4010", origin.originRule());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -41,5 +44,9 @@ public final class TrustedOriginTest {
|
|||
IllegalArgumentException.class,
|
||||
() -> TrustedOrigin.parse("https://help.example?redirect=evil", false)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> TrustedOrigin.parse("https://help.example/app", false)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,11 +56,46 @@ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
|||
})
|
||||
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
|
||||
const activateProtectedTokenFragment = () => {
|
||||
if (!window.location.hash.startsWith("#token=")) return
|
||||
|
||||
const token = new URLSearchParams(window.location.hash.slice(1)).get("token")
|
||||
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`)
|
||||
|
||||
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return
|
||||
|
||||
const candidates = [
|
||||
{
|
||||
form: document.getElementById("magic-link-fragment-form"),
|
||||
input: document.getElementById("magic-link-fragment-token"),
|
||||
options: document.getElementById("standard-login-options")
|
||||
},
|
||||
{
|
||||
form: document.getElementById("email-change-fragment-form"),
|
||||
input: document.getElementById("email-change-fragment-token")
|
||||
}
|
||||
]
|
||||
|
||||
const candidate = candidates.find(({form, input}) =>
|
||||
form instanceof HTMLFormElement && input instanceof HTMLInputElement
|
||||
)
|
||||
|
||||
if (!candidate) return
|
||||
|
||||
candidate.input.value = token
|
||||
candidate.form.hidden = false
|
||||
if (candidate.options instanceof HTMLElement) candidate.options.hidden = true
|
||||
candidate.form.querySelector("button")?.focus()
|
||||
}
|
||||
|
||||
activateProtectedTokenFragment()
|
||||
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {
|
||||
_csrf_token: csrfToken,
|
||||
client_type: window.WhoNeedHelpAndroid ? "android" : "browser"
|
||||
client_type: typeof window.WhoNeedHelpAndroid?.postMessage === "function" ? "android" : "browser"
|
||||
},
|
||||
hooks: {...colocatedHooks, ...Hooks},
|
||||
})
|
||||
|
|
@ -74,10 +109,14 @@ window.addEventListener("phx:reset-message-form", ({detail}) => {
|
|||
if (form instanceof HTMLFormElement) form.reset()
|
||||
})
|
||||
window.addEventListener("phx:native-tracking-start", ({detail}) => {
|
||||
window.WhoNeedHelpAndroid?.startTracking(detail.assignment_id, csrfToken)
|
||||
window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({
|
||||
action: "start",
|
||||
assignment_id: detail.assignment_id,
|
||||
csrf_token: csrfToken
|
||||
}))
|
||||
})
|
||||
window.addEventListener("phx:native-tracking-stop", () => {
|
||||
window.WhoNeedHelpAndroid?.stopTracking()
|
||||
window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({action: "stop"}))
|
||||
})
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import maplibregl from "maplibre-gl"
|
||||
|
||||
const trackingMinTimeMs = 5000
|
||||
|
||||
const defaultStyle = {
|
||||
version: 8,
|
||||
sources: {
|
||||
|
|
@ -134,8 +136,43 @@ export const Hooks = {
|
|||
LiveTracking: {
|
||||
mounted() {
|
||||
this.destroying = false
|
||||
this.lastLocationSentAt = 0
|
||||
this.pendingLocation = null
|
||||
this.locationTimer = undefined
|
||||
|
||||
if (window.WhoNeedHelpAndroid) {
|
||||
this.sendLocation = payload => {
|
||||
this.lastLocationSentAt = Date.now()
|
||||
this.pendingLocation = null
|
||||
this.pushEvent("location-update", payload)
|
||||
}
|
||||
|
||||
this.queueLocation = payload => {
|
||||
const elapsed = Date.now() - this.lastLocationSentAt
|
||||
|
||||
if (this.lastLocationSentAt === 0 || elapsed >= trackingMinTimeMs) {
|
||||
if (this.locationTimer !== undefined) {
|
||||
window.clearTimeout(this.locationTimer)
|
||||
this.locationTimer = undefined
|
||||
}
|
||||
|
||||
this.sendLocation(payload)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingLocation = payload
|
||||
|
||||
if (this.locationTimer === undefined) {
|
||||
this.locationTimer = window.setTimeout(() => {
|
||||
this.locationTimer = undefined
|
||||
|
||||
if (!this.destroying && this.pendingLocation) {
|
||||
this.sendLocation(this.pendingLocation)
|
||||
}
|
||||
}, trackingMinTimeMs - elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window.WhoNeedHelpAndroid?.postMessage === "function") {
|
||||
this.nativeError = () => {
|
||||
this.pushEvent("location-error", {})
|
||||
this.pushEvent("stop-tracking", {})
|
||||
|
|
@ -145,7 +182,7 @@ export const Hooks = {
|
|||
}
|
||||
|
||||
this.watchId = navigator.geolocation.watchPosition(
|
||||
position => this.pushEvent("location-update", {
|
||||
position => this.queueLocation({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy_meters: position.coords.accuracy
|
||||
|
|
@ -158,6 +195,13 @@ export const Hooks = {
|
|||
},
|
||||
destroyed() {
|
||||
this.destroying = true
|
||||
this.pendingLocation = null
|
||||
|
||||
if (this.locationTimer !== undefined) {
|
||||
window.clearTimeout(this.locationTimer)
|
||||
this.locationTimer = undefined
|
||||
}
|
||||
|
||||
if (this.nativeError) {
|
||||
window.removeEventListener("wnh:native-tracking-error", this.nativeError)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ x-boundary-app-environment: &boundary-app-environment
|
|||
GITHUB_OAUTH_AUTHORIZE_URL: http://external-mock:8080/oauth/authorize
|
||||
GITHUB_OAUTH_TOKEN_URL: http://external-mock:8080/oauth/token
|
||||
GITHUB_OAUTH_USER_URL: http://external-mock:8080/oauth/user
|
||||
ALLOW_INSECURE_EXTERNAL_HTTP: "true"
|
||||
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS: "100"
|
||||
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: "100"
|
||||
SMTP_RELAY: external-mock
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ config :who_need_help,
|
|||
app_role: :web,
|
||||
codex_session_id: "not-configured",
|
||||
e2e_routes: false,
|
||||
secure_cookies: false,
|
||||
rate_limit_policies: %{},
|
||||
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
|
||||
|
|
@ -37,6 +38,9 @@ config :who_need_help, WhoNeedHelp.Repo, types: WhoNeedHelp.PostgrexTypes
|
|||
|
||||
config :geo_postgis, json_library: Jason
|
||||
|
||||
config :phoenix,
|
||||
filter_parameters: ["password", "token", "secret", "authorization", "cookie", "code"]
|
||||
|
||||
config :who_need_help, Oban,
|
||||
repo: WhoNeedHelp.Repo,
|
||||
queues: [maintenance: 2, push: 1],
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ e2e_routes =
|
|||
end
|
||||
|
||||
config :who_need_help, :e2e_routes, e2e_routes
|
||||
config :who_need_help, :secure_cookies, true
|
||||
|
||||
config :who_need_help, WhoNeedHelpWeb.Endpoint,
|
||||
force_ssl: [
|
||||
|
|
|
|||
|
|
@ -8,6 +8,42 @@ app_role =
|
|||
other -> raise "APP_ROLE must be web, worker, or migrate; got #{inspect(other)}"
|
||||
end
|
||||
|
||||
rate_limit_policies =
|
||||
case System.get_env("RATE_LIMIT_POLICIES_JSON") do
|
||||
value when value in [nil, ""] ->
|
||||
%{}
|
||||
|
||||
json ->
|
||||
case Jason.decode(json) do
|
||||
{:ok, policies} when is_map(policies) ->
|
||||
invalid_action =
|
||||
Enum.find_value(policies, fn
|
||||
{action, %{"limit" => limit, "window_seconds" => window}}
|
||||
when is_binary(action) and action != "" and is_integer(limit) and limit > 0 and
|
||||
is_integer(window) and window > 0 ->
|
||||
nil
|
||||
|
||||
{action, _policy} ->
|
||||
if is_binary(action) and action != "", do: action, else: "<invalid action>"
|
||||
end)
|
||||
|
||||
if invalid_action do
|
||||
raise """
|
||||
RATE_LIMIT_POLICIES_JSON policy #{inspect(invalid_action)} must contain positive \
|
||||
integer limit and window_seconds values.
|
||||
"""
|
||||
end
|
||||
|
||||
policies
|
||||
|
||||
{:ok, _other} ->
|
||||
raise "RATE_LIMIT_POLICIES_JSON must be a JSON object."
|
||||
|
||||
{:error, _reason} ->
|
||||
raise "RATE_LIMIT_POLICIES_JSON must contain valid JSON."
|
||||
end
|
||||
end
|
||||
|
||||
config :who_need_help,
|
||||
app_role: app_role,
|
||||
codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"),
|
||||
|
|
@ -16,12 +52,7 @@ config :who_need_help,
|
|||
"MAP_TILE_URL",
|
||||
Application.fetch_env!(:who_need_help, :map_tile_url)
|
||||
),
|
||||
rate_limit_policies:
|
||||
(case System.get_env("RATE_LIMIT_POLICIES_JSON") do
|
||||
nil -> %{}
|
||||
"" -> %{}
|
||||
json -> Jason.decode!(json)
|
||||
end)
|
||||
rate_limit_policies: rate_limit_policies
|
||||
|
||||
optional_positive_integer = fn name ->
|
||||
case System.get_env(name) do
|
||||
|
|
@ -74,6 +105,13 @@ required_non_negative_integer = fn name ->
|
|||
end
|
||||
end
|
||||
|
||||
allow_insecure_external_http =
|
||||
case System.get_env("ALLOW_INSECURE_EXTERNAL_HTTP", "false") do
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
other -> raise "ALLOW_INSECURE_EXTERNAL_HTTP must be true or false; got #{inspect(other)}"
|
||||
end
|
||||
|
||||
oauth_endpoint = fn name, default ->
|
||||
value =
|
||||
case System.get_env(name) do
|
||||
|
|
@ -83,11 +121,15 @@ oauth_endpoint = fn name, default ->
|
|||
|
||||
case URI.parse(value) do
|
||||
%URI{scheme: scheme, host: host}
|
||||
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||
when is_binary(host) and host != "" and
|
||||
(scheme == "https" or (scheme == "http" and allow_insecure_external_http)) ->
|
||||
value
|
||||
|
||||
_other ->
|
||||
raise "#{name} must be an absolute HTTP or HTTPS URL."
|
||||
raise """
|
||||
#{name} must be an absolute HTTPS URL. Plain HTTP is allowed only in the \
|
||||
isolated external-boundary drill with ALLOW_INSECURE_EXTERNAL_HTTP=true.
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -155,11 +197,15 @@ push_configuration =
|
|||
bearer_token != "" ->
|
||||
case URI.parse(endpoint) do
|
||||
%URI{scheme: scheme, host: host}
|
||||
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||
when is_binary(host) and host != "" and
|
||||
(scheme == "https" or (scheme == "http" and allow_insecure_external_http)) ->
|
||||
:ok
|
||||
|
||||
_other ->
|
||||
raise "PUSH_HTTP_ENDPOINT must be an absolute HTTP or HTTPS URL."
|
||||
raise """
|
||||
PUSH_HTTP_ENDPOINT must be an absolute HTTPS URL. Plain HTTP is allowed only \
|
||||
in the isolated external-boundary drill with ALLOW_INSECURE_EXTERNAL_HTTP=true.
|
||||
"""
|
||||
end
|
||||
|
||||
[
|
||||
|
|
|
|||
|
|
@ -37,11 +37,13 @@ The Android module is a thin same-origin WebView shell with one native
|
|||
foreground location service. Its debug origin is supplied at build time from
|
||||
the repository's ignored `.env`; release builds require an explicit HTTPS
|
||||
origin. Authentication cookies, LiveView WebSockets, MapLibre, and private chat
|
||||
use the same Phoenix application as the browser. A JavaScript bridge starts the
|
||||
native service only from the visible Activity; the service posts the current
|
||||
point through CSRF-protected, participant-authorized same-origin routes and
|
||||
shows a persistent notification with Stop. Production signing and distribution
|
||||
are separate operational work and are not represented as complete.
|
||||
use the same Phoenix application as the browser. An origin-restricted
|
||||
`WebViewCompat` message listener accepts tracking commands only from the trusted
|
||||
main frame and starts the native service only from the visible Activity; the
|
||||
service posts the current point through CSRF-protected,
|
||||
participant-authorized same-origin routes and shows a persistent notification
|
||||
with Stop. Production signing and distribution are separate operational work
|
||||
and are not represented as complete.
|
||||
|
||||
## Application boundaries
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,11 @@ network. It generates independent one-run OAuth and push credentials in an
|
|||
ignored mode-`0600` environment file, builds the production release plus a
|
||||
non-root standard-library Python protocol mock, and then verifies:
|
||||
|
||||
`compose.external-boundaries.yaml` is the only deployment file that sets
|
||||
`ALLOW_INSECURE_EXTERNAL_HTTP=true`. Ordinary runtime configuration requires
|
||||
HTTPS for OAuth and push endpoints so provider credentials are not sent over
|
||||
plain HTTP.
|
||||
|
||||
1. GitHub-compatible OAuth authorization, PKCE S256, token exchange, normalized
|
||||
user lookup, state mismatch, provider rejection, one-time-code replay,
|
||||
a fresh flow after a temporary token error, and a token timeout;
|
||||
|
|
|
|||
|
|
@ -47,12 +47,16 @@ test("registration, email confirmation, password, and email change work end to e
|
|||
const emailChangeLink = await waitForApplicationEmailLink(
|
||||
request,
|
||||
changedEmail,
|
||||
"/users/settings/confirm-email/",
|
||||
"/users/settings/confirm-email",
|
||||
);
|
||||
recoverableOneTimeNavigations.add(emailChangeLink);
|
||||
|
||||
try {
|
||||
await user.page.goto(emailChangeLink);
|
||||
await expect(user.page.locator("#email-change-fragment-form")).toBeVisible();
|
||||
await user.page
|
||||
.getByRole("button", { name: "Confirm email change" })
|
||||
.click();
|
||||
await expect(user.page.getByText("Email changed successfully.")).toBeVisible();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ async function waitForMagicLink(
|
|||
return waitForApplicationEmailLink(
|
||||
request,
|
||||
email,
|
||||
"/users/log-in/",
|
||||
"/users/log-in",
|
||||
previousMessageID,
|
||||
);
|
||||
}
|
||||
|
|
@ -325,7 +325,7 @@ export async function registerAndConfirm(
|
|||
await expect(page.getByText(`An email was sent to ${email}`)).toBeVisible();
|
||||
|
||||
await gotoWithTransientRetry(page, await waitForMagicLink(request, email));
|
||||
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
|
||||
await expect(page.locator("#magic-link-fragment-form")).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: "Confirm and log in only this time" })
|
||||
.click();
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
|
|||
"path" => "/requests/#{fixture.request.id}"
|
||||
},
|
||||
"assignment" => %{"id" => fixture.assignment.id},
|
||||
"helper_login_path" => "/users/log-in/#{fixture.helper_login_token}"
|
||||
"helper_login_path" => "/users/log-in#token=#{fixture.helper_login_token}"
|
||||
}
|
||||
|
||||
context.manifest_path |> Path.dirname() |> File.mkdir_p!()
|
||||
|
|
@ -350,7 +350,7 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
|
|||
unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
|
||||
manifest["database"] == context.database and valid_users? and valid_request? and
|
||||
valid_assignment? and is_binary(manifest["helper_login_path"]) and
|
||||
String.starts_with?(manifest["helper_login_path"], "/users/log-in/") do
|
||||
String.starts_with?(manifest["helper_login_path"], "/users/log-in#token=") do
|
||||
Mix.raise("Android staging E2E manifest does not match the requested run and database")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ defmodule WhoNeedHelp.Accounts do
|
|||
|
||||
"""
|
||||
def get_user_by_email(email) when is_binary(email) do
|
||||
Repo.get_by(User, email: email)
|
||||
Repo.get_by(User, email: String.trim(email))
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -78,7 +78,7 @@ defmodule WhoNeedHelp.Accounts do
|
|||
"""
|
||||
def get_user_by_email_and_password(email, password)
|
||||
when is_binary(email) and is_binary(password) do
|
||||
user = Repo.get_by(User, email: email)
|
||||
user = get_user_by_email(email)
|
||||
|
||||
if User.valid_password?(user, password) and user.moderation_status != :suspended,
|
||||
do: user
|
||||
|
|
@ -381,9 +381,9 @@ defmodule WhoNeedHelp.Accounts do
|
|||
|
||||
Repo.transact(fn ->
|
||||
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
||||
%UserToken{sent_to: email} <- Repo.one(query),
|
||||
%UserToken{sent_to: email} <- query |> lock("FOR UPDATE") |> Repo.one(),
|
||||
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
|
||||
{_count, _result} <-
|
||||
{count, _result} when count > 0 <-
|
||||
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
|
||||
{:ok, user}
|
||||
else
|
||||
|
|
@ -480,34 +480,46 @@ defmodule WhoNeedHelp.Accounts do
|
|||
`mix help phx.gen.auth`.
|
||||
"""
|
||||
def login_user_by_magic_link(token) do
|
||||
{:ok, query} = UserToken.verify_magic_link_token_query(token)
|
||||
with {:ok, query} <- UserToken.verify_magic_link_token_query(token) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
case query |> lock("FOR UPDATE") |> Repo.one() do
|
||||
{%User{moderation_status: :suspended}, token} ->
|
||||
with {:ok, _token} <- Repo.delete(token) do
|
||||
{:ok, {:rejected, :not_found}}
|
||||
end
|
||||
|
||||
case Repo.one(query) do
|
||||
{%User{moderation_status: :suspended}, token} ->
|
||||
Repo.delete!(token)
|
||||
{:error, :not_found}
|
||||
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
|
||||
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
|
||||
raise """
|
||||
magic link log in is not allowed for unconfirmed users with a password set!
|
||||
|
||||
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
|
||||
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
|
||||
raise """
|
||||
magic link log in is not allowed for unconfirmed users with a password set!
|
||||
This cannot happen with the default implementation, which indicates that you
|
||||
might have adapted the code to a different use case. Please make sure to read the
|
||||
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
|
||||
"""
|
||||
|
||||
This cannot happen with the default implementation, which indicates that you
|
||||
might have adapted the code to a different use case. Please make sure to read the
|
||||
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
|
||||
"""
|
||||
{%User{confirmed_at: nil} = user, _token} ->
|
||||
user
|
||||
|> User.confirm_changeset()
|
||||
|> update_user_and_delete_all_tokens_in_transaction()
|
||||
|
||||
{%User{confirmed_at: nil} = user, _token} ->
|
||||
user
|
||||
|> User.confirm_changeset()
|
||||
|> update_user_and_delete_all_tokens()
|
||||
{user, token} ->
|
||||
with {:ok, _token} <- Repo.delete(token) do
|
||||
{:ok, {user, []}}
|
||||
end
|
||||
|
||||
{user, token} ->
|
||||
Repo.delete!(token)
|
||||
{:ok, {user, []}}
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
case result do
|
||||
{:ok, {:rejected, reason}} -> {:error, reason}
|
||||
other -> other
|
||||
end
|
||||
else
|
||||
_invalid_token -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -516,7 +528,7 @@ defmodule WhoNeedHelp.Accounts do
|
|||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
|
||||
iex> deliver_user_update_email_instructions(user, current_email, &"https://example.test/users/settings/confirm-email#token=#{&1}")
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
|
|
@ -546,6 +558,12 @@ defmodule WhoNeedHelp.Accounts do
|
|||
:ok
|
||||
end
|
||||
|
||||
def delete_expired_user_tokens(now \\ DateTime.utc_now(:second)) do
|
||||
now
|
||||
|> UserToken.expired_tokens_query()
|
||||
|> Repo.delete_all()
|
||||
end
|
||||
|
||||
## Token helper
|
||||
|
||||
defp before_moderation_user(query, nil), do: query
|
||||
|
|
@ -567,14 +585,16 @@ defmodule WhoNeedHelp.Accounts do
|
|||
end
|
||||
|
||||
defp update_user_and_delete_all_tokens(changeset) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, user} <- Repo.update(changeset) do
|
||||
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
|
||||
Repo.transact(fn -> update_user_and_delete_all_tokens_in_transaction(changeset) end)
|
||||
end
|
||||
|
||||
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
|
||||
defp update_user_and_delete_all_tokens_in_transaction(changeset) do
|
||||
with {:ok, user} <- Repo.update(changeset) do
|
||||
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
|
||||
|
||||
{:ok, {user, tokens_to_expire}}
|
||||
end
|
||||
end)
|
||||
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
|
||||
|
||||
{:ok, {user, tokens_to_expire}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity do
|
|||
identity
|
||||
|> cast(attrs, [:provider, :profile_url, :handle])
|
||||
|> validate_required([:provider, :profile_url, :user_id])
|
||||
|> validate_format(:profile_url, ~r/^https?:\/\/[^\s]+$/i,
|
||||
message: "must be a complete http(s) URL"
|
||||
|> validate_format(:profile_url, ~r/^https:\/\/[^\s]+$/i,
|
||||
message: "must be a complete HTTPS URL"
|
||||
)
|
||||
|> validate_length(:profile_url, max: 500)
|
||||
|> validate_length(:profile_url, max: 255)
|
||||
|> validate_length(:handle, max: 100)
|
||||
|> unique_constraint([:provider, :provider_uid])
|
||||
end
|
||||
|
|
@ -51,7 +51,7 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity do
|
|||
message: "must be a GitHub profile URL"
|
||||
)
|
||||
|> validate_length(:provider_uid, max: 255)
|
||||
|> validate_length(:profile_url, max: 500)
|
||||
|> validate_length(:profile_url, max: 255)
|
||||
|> validate_length(:handle, max: 100)
|
||||
|> unique_constraint([:provider, :provider_uid])
|
||||
end
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ defmodule WhoNeedHelp.Accounts.User do
|
|||
|> validate_length(:display_name, min: 2, max: 80)
|
||||
|> validate_length(:bio, max: 600)
|
||||
|> validate_inclusion(:locale, ~w(en uk ru))
|
||||
|> validate_length(:tip_url, max: 255)
|
||||
|> validate_url(:tip_url)
|
||||
end
|
||||
|
||||
|
|
@ -108,12 +109,12 @@ defmodule WhoNeedHelp.Accounts.User do
|
|||
defp validate_url(changeset, field) do
|
||||
validate_change(changeset, field, fn ^field, value ->
|
||||
case URI.parse(value) do
|
||||
%URI{scheme: scheme, host: host}
|
||||
when scheme in ["https", "http"] and is_binary(host) and host != "" ->
|
||||
%URI{scheme: "https", host: host}
|
||||
when is_binary(host) and host != "" ->
|
||||
[]
|
||||
|
||||
_ ->
|
||||
[{field, "must be a full http(s) URL"}]
|
||||
[{field, "must be a full HTTPS URL"}]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -153,6 +153,18 @@ defmodule WhoNeedHelp.Accounts.UserToken do
|
|||
end
|
||||
end
|
||||
|
||||
def expired_tokens_query(now \\ DateTime.utc_now(:second)) do
|
||||
magic_link_cutoff = DateTime.add(now, -@magic_link_validity_in_minutes, :minute)
|
||||
change_email_cutoff = DateTime.add(now, -@change_email_validity_in_days, :day)
|
||||
session_cutoff = DateTime.add(now, -@session_validity_in_days, :day)
|
||||
|
||||
from token in UserToken,
|
||||
where:
|
||||
(token.context == "login" and token.inserted_at <= ^magic_link_cutoff) or
|
||||
(like(token.context, "change:%") and token.inserted_at <= ^change_email_cutoff) or
|
||||
(token.context == "session" and token.inserted_at <= ^session_cutoff)
|
||||
end
|
||||
|
||||
defp by_token_and_context_query(token, context) do
|
||||
from UserToken, where: [token: ^token, context: ^context]
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,13 +19,15 @@ defmodule WhoNeedHelp.Activities do
|
|||
|
||||
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)
|
||||
|
||||
def subscribe_user(%Scope{user: user}),
|
||||
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activities:user:#{user.id}")
|
||||
|
||||
def subscribe_activity(id),
|
||||
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activity:#{id}")
|
||||
|
||||
def notify_activity_updated(id) do
|
||||
activity = load_activity(id)
|
||||
broadcast({:activity_updated, activity})
|
||||
broadcast_activity(id, {:activity_updated, activity})
|
||||
broadcast({:activity_updated, load_activity_summary(id)})
|
||||
broadcast_activity(id, {:activity_updated, load_activity(id)})
|
||||
:ok
|
||||
end
|
||||
|
||||
|
|
@ -37,7 +39,7 @@ defmodule WhoNeedHelp.Activities do
|
|||
now = DateTime.utc_now(:second)
|
||||
limit = Pagination.limit(options)
|
||||
cursor = Pagination.cursor(options)
|
||||
public_creator = Accounts.public_user_query(social_identities: true)
|
||||
public_creator = Accounts.public_user_query()
|
||||
|
||||
Activity
|
||||
|> where(
|
||||
|
|
@ -231,39 +233,41 @@ defmodule WhoNeedHelp.Activities do
|
|||
end
|
||||
|
||||
with {:ok, activity} <- result do
|
||||
activity = load_activity(activity.id)
|
||||
broadcast({:activity_created, activity})
|
||||
{:ok, activity}
|
||||
broadcast({:activity_created, load_activity_summary(activity.id)})
|
||||
{:ok, load_activity(activity.id)}
|
||||
end
|
||||
end
|
||||
|
||||
def request_to_join(%Scope{user: user} = scope, activity_id) do
|
||||
result =
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :join_activity) do
|
||||
with {:ok, activity_id} <- cast_id(activity_id),
|
||||
{:ok, _limit} <- Trust.authorize_action(scope, :join_activity) do
|
||||
Repo.transact(fn ->
|
||||
activity = locked_activity(activity_id)
|
||||
now = DateTime.utc_now(:second)
|
||||
with %Activity{} = activity <- locked_activity(activity_id) do
|
||||
now = DateTime.utc_now(:second)
|
||||
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
|
||||
|
||||
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
|
||||
cond do
|
||||
activity.creator_id == user.id ->
|
||||
{:error, :organizer_already_joined}
|
||||
|
||||
cond do
|
||||
activity.creator_id == user.id ->
|
||||
{:error, :organizer_already_joined}
|
||||
activity.status != :open or not is_nil(activity.hidden_at) ->
|
||||
{:error, :not_open}
|
||||
|
||||
activity.status != :open or not is_nil(activity.hidden_at) ->
|
||||
{:error, :not_open}
|
||||
DateTime.compare(activity.join_deadline, now) != :gt ->
|
||||
{:error, :join_closed}
|
||||
|
||||
DateTime.compare(activity.join_deadline, now) != :gt ->
|
||||
{:error, :join_closed}
|
||||
Trust.blocked_between?(activity.creator_id, user.id) ->
|
||||
{:error, :blocked}
|
||||
|
||||
Trust.blocked_between?(activity.creator_id, user.id) ->
|
||||
{:error, :blocked}
|
||||
approved_count(activity.id) >= activity.capacity ->
|
||||
{:error, :capacity_reached}
|
||||
|
||||
approved_count(activity.id) >= activity.capacity ->
|
||||
{:error, :capacity_reached}
|
||||
|
||||
true ->
|
||||
upsert_join_request(activity.id, user.id, now)
|
||||
true ->
|
||||
upsert_join_request(activity.id, user.id, now)
|
||||
end
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -275,44 +279,47 @@ defmodule WhoNeedHelp.Activities do
|
|||
result =
|
||||
with {:ok, participant_id} <- cast_id(participant_id) do
|
||||
Repo.transact(fn ->
|
||||
participant = locked_participant(participant_id)
|
||||
activity = locked_activity(participant.activity_id)
|
||||
:ok = Trust.lock_user_pair(activity.creator_id, participant.user_id)
|
||||
with %Participant{} = participant <- locked_participant(participant_id),
|
||||
%Activity{} = activity <- locked_activity(participant.activity_id) do
|
||||
:ok = Trust.lock_user_pair(activity.creator_id, participant.user_id)
|
||||
|
||||
cond do
|
||||
activity.creator_id != organizer.id ->
|
||||
{:error, :forbidden}
|
||||
cond do
|
||||
activity.creator_id != organizer.id ->
|
||||
{:error, :forbidden}
|
||||
|
||||
participant.status != :requested ->
|
||||
{:error, :invalid_transition}
|
||||
participant.status != :requested ->
|
||||
{:error, :invalid_transition}
|
||||
|
||||
activity.status != :open ->
|
||||
{:error, :not_open}
|
||||
activity.status != :open ->
|
||||
{:error, :not_open}
|
||||
|
||||
Trust.blocked_between?(activity.creator_id, participant.user_id) ->
|
||||
{:error, :blocked}
|
||||
Trust.blocked_between?(activity.creator_id, participant.user_id) ->
|
||||
{:error, :blocked}
|
||||
|
||||
approved_count(activity.id) >= activity.capacity ->
|
||||
{:error, :capacity_reached}
|
||||
approved_count(activity.id) >= activity.capacity ->
|
||||
{:error, :capacity_reached}
|
||||
|
||||
true ->
|
||||
with {:ok, participant} <-
|
||||
participant
|
||||
|> Participant.changeset(%{
|
||||
status: :approved,
|
||||
reviewed_at: DateTime.utc_now(:second)
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
organizer.id,
|
||||
"activity.participant_approved",
|
||||
"activity_participant",
|
||||
participant.id,
|
||||
%{"activity_id" => activity.id, "user_id" => participant.user_id}
|
||||
) do
|
||||
{:ok, participant}
|
||||
end
|
||||
true ->
|
||||
with {:ok, participant} <-
|
||||
participant
|
||||
|> Participant.changeset(%{
|
||||
status: :approved,
|
||||
reviewed_at: DateTime.utc_now(:second)
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
organizer.id,
|
||||
"activity.participant_approved",
|
||||
"activity_participant",
|
||||
participant.id,
|
||||
%{"activity_id" => activity.id, "user_id" => participant.user_id}
|
||||
) do
|
||||
{:ok, participant}
|
||||
end
|
||||
end
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -328,40 +335,42 @@ defmodule WhoNeedHelp.Activities do
|
|||
|
||||
def leave_activity(%Scope{user: user}, activity_id) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
participant =
|
||||
Participant
|
||||
|> where(
|
||||
[participant],
|
||||
participant.activity_id == ^activity_id and participant.user_id == ^user.id
|
||||
)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
with {:ok, activity_id} <- cast_id(activity_id) do
|
||||
Repo.transact(fn ->
|
||||
participant =
|
||||
Participant
|
||||
|> where(
|
||||
[participant],
|
||||
participant.activity_id == ^activity_id and participant.user_id == ^user.id
|
||||
)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
cond do
|
||||
is_nil(participant) ->
|
||||
{:error, :not_found}
|
||||
cond do
|
||||
is_nil(participant) ->
|
||||
{:error, :not_found}
|
||||
|
||||
participant.role == :organizer ->
|
||||
{:error, :organizer_cannot_leave}
|
||||
participant.role == :organizer ->
|
||||
{:error, :organizer_cannot_leave}
|
||||
|
||||
participant.status not in [:requested, :approved] ->
|
||||
{:error, :invalid_transition}
|
||||
participant.status not in [:requested, :approved] ->
|
||||
{:error, :invalid_transition}
|
||||
|
||||
true ->
|
||||
with {:ok, participant} <-
|
||||
participant
|
||||
|> Participant.changeset(%{
|
||||
status: :left,
|
||||
left_at: DateTime.utc_now(:second)
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(user.id, "activity.left", "activity", activity_id) do
|
||||
{:ok, participant}
|
||||
end
|
||||
end
|
||||
end)
|
||||
true ->
|
||||
with {:ok, participant} <-
|
||||
participant
|
||||
|> Participant.changeset(%{
|
||||
status: :left,
|
||||
left_at: DateTime.utc_now(:second)
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(user.id, "activity.left", "activity", activity_id) do
|
||||
{:ok, participant}
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
after_participant_change(result, activity_id, :participant_left)
|
||||
end
|
||||
|
|
@ -376,29 +385,33 @@ defmodule WhoNeedHelp.Activities do
|
|||
|
||||
def send_message(%Scope{user: user} = scope, activity_id, attrs) do
|
||||
result =
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message) do
|
||||
with {:ok, activity_id} <- cast_id(activity_id),
|
||||
{:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message) do
|
||||
Repo.transact(fn ->
|
||||
activity = locked_activity(activity_id)
|
||||
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
|
||||
with %Activity{} = activity <- locked_activity(activity_id) do
|
||||
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
|
||||
|
||||
cond do
|
||||
activity.status != :open or not is_nil(activity.hidden_at) ->
|
||||
{:error, :not_open}
|
||||
cond do
|
||||
activity.status != :open or not is_nil(activity.hidden_at) ->
|
||||
{:error, :not_open}
|
||||
|
||||
Trust.blocked_between?(activity.creator_id, user.id) ->
|
||||
{:error, :blocked}
|
||||
Trust.blocked_between?(activity.creator_id, user.id) ->
|
||||
{:error, :blocked}
|
||||
|
||||
not approved_participant_id?(activity.id, user.id) ->
|
||||
{:error, :forbidden}
|
||||
not approved_participant_id?(activity.id, user.id) ->
|
||||
{:error, :forbidden}
|
||||
|
||||
true ->
|
||||
%Message{}
|
||||
|> Message.changeset(%{
|
||||
body: attrs["body"] || attrs[:body],
|
||||
activity_id: activity.id,
|
||||
sender_id: user.id
|
||||
})
|
||||
|> Repo.insert()
|
||||
true ->
|
||||
%Message{}
|
||||
|> Message.changeset(%{
|
||||
body: attrs["body"] || attrs[:body],
|
||||
activity_id: activity.id,
|
||||
sender_id: user.id
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -514,34 +527,36 @@ defmodule WhoNeedHelp.Activities do
|
|||
defp transition_participant(organizer_id, participant_id, status) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
participant = locked_participant(participant_id)
|
||||
activity = locked_activity(participant.activity_id)
|
||||
with %Participant{} = participant <- locked_participant(participant_id),
|
||||
%Activity{} = activity <- locked_activity(participant.activity_id) do
|
||||
cond do
|
||||
activity.creator_id != organizer_id ->
|
||||
{:error, :forbidden}
|
||||
|
||||
cond do
|
||||
activity.creator_id != organizer_id ->
|
||||
{:error, :forbidden}
|
||||
participant.status != :requested ->
|
||||
{:error, :invalid_transition}
|
||||
|
||||
participant.status != :requested ->
|
||||
{:error, :invalid_transition}
|
||||
|
||||
true ->
|
||||
with {:ok, participant} <-
|
||||
participant
|
||||
|> Participant.changeset(%{
|
||||
status: status,
|
||||
reviewed_at: DateTime.utc_now(:second)
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
organizer_id,
|
||||
"activity.participant_#{status}",
|
||||
"activity_participant",
|
||||
participant.id,
|
||||
%{"activity_id" => activity.id, "user_id" => participant.user_id}
|
||||
) do
|
||||
{:ok, participant}
|
||||
end
|
||||
true ->
|
||||
with {:ok, participant} <-
|
||||
participant
|
||||
|> Participant.changeset(%{
|
||||
status: status,
|
||||
reviewed_at: DateTime.utc_now(:second)
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
organizer_id,
|
||||
"activity.participant_#{status}",
|
||||
"activity_participant",
|
||||
participant.id,
|
||||
%{"activity_id" => activity.id, "user_id" => participant.user_id}
|
||||
) do
|
||||
{:ok, participant}
|
||||
end
|
||||
end
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -550,37 +565,42 @@ defmodule WhoNeedHelp.Activities do
|
|||
|
||||
defp transition_activity(user_id, activity_id, target_status) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
activity = locked_activity(activity_id)
|
||||
with {:ok, activity_id} <- cast_id(activity_id) do
|
||||
Repo.transact(fn ->
|
||||
with %Activity{} = activity <- locked_activity(activity_id) do
|
||||
cond do
|
||||
activity.creator_id != user_id ->
|
||||
{:error, :forbidden}
|
||||
|
||||
cond do
|
||||
activity.creator_id != user_id ->
|
||||
{:error, :forbidden}
|
||||
activity.status != :open ->
|
||||
{:error, :invalid_transition}
|
||||
|
||||
activity.status != :open ->
|
||||
{:error, :invalid_transition}
|
||||
true ->
|
||||
timestamp = DateTime.utc_now(:second)
|
||||
|
||||
true ->
|
||||
timestamp = DateTime.utc_now(:second)
|
||||
attrs =
|
||||
case target_status do
|
||||
:cancelled -> %{status: :cancelled, cancelled_at: timestamp}
|
||||
:completed -> %{status: :completed, completed_at: timestamp}
|
||||
end
|
||||
|
||||
attrs =
|
||||
case target_status do
|
||||
:cancelled -> %{status: :cancelled, cancelled_at: timestamp}
|
||||
:completed -> %{status: :completed, completed_at: timestamp}
|
||||
end
|
||||
|
||||
with {:ok, activity} <- activity |> Ecto.Changeset.change(attrs) |> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
user_id,
|
||||
"activity.#{target_status}",
|
||||
"activity",
|
||||
activity.id
|
||||
) do
|
||||
{:ok, activity}
|
||||
with {:ok, activity} <-
|
||||
activity |> Ecto.Changeset.change(attrs) |> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
user_id,
|
||||
"activity.#{target_status}",
|
||||
"activity",
|
||||
activity.id
|
||||
) do
|
||||
{:ok, activity}
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
with {:ok, activity} <- result do
|
||||
activity = load_activity(activity.id)
|
||||
|
|
@ -592,9 +612,10 @@ defmodule WhoNeedHelp.Activities do
|
|||
|
||||
defp after_participant_change({:ok, participant}, activity_id, event)
|
||||
when is_binary(activity_id) do
|
||||
activity = load_activity(activity_id)
|
||||
broadcast({:activity_updated, activity})
|
||||
broadcast_activity(activity.id, {event, participant})
|
||||
summary = load_activity_summary(activity_id)
|
||||
broadcast({:activity_updated, summary})
|
||||
broadcast_user(participant.user_id, {:my_activity_updated, summary, member?(participant)})
|
||||
broadcast_activity(activity_id, {event, participant})
|
||||
{:ok, participant}
|
||||
end
|
||||
|
||||
|
|
@ -619,6 +640,16 @@ defmodule WhoNeedHelp.Activities do
|
|||
|> preload_activity()
|
||||
end
|
||||
|
||||
defp load_activity_summary(id) do
|
||||
public_creator = Accounts.public_user_query()
|
||||
|
||||
Activity
|
||||
|> where([activity], activity.id == ^id)
|
||||
|> with_approved_participant_count()
|
||||
|> preload([activity], category: :parent, creator: ^public_creator)
|
||||
|> Repo.one!()
|
||||
end
|
||||
|
||||
defp get_loaded_activity(id) do
|
||||
Activity
|
||||
|> Repo.get(id)
|
||||
|
|
@ -682,20 +713,24 @@ defmodule WhoNeedHelp.Activities do
|
|||
Activity
|
||||
|> where([activity], activity.id == ^id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp locked_participant(id) do
|
||||
Participant
|
||||
|> where([participant], participant.id == ^id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp maybe_filter_category(query, value) when value in [nil, ""], do: query
|
||||
|
||||
defp maybe_filter_category(query, value),
|
||||
do: where(query, [activity], activity.category_id == ^value)
|
||||
defp maybe_filter_category(query, value) do
|
||||
case Ecto.UUID.cast(value) do
|
||||
{:ok, category_id} -> where(query, [activity], activity.category_id == ^category_id)
|
||||
:error -> where(query, [activity], false)
|
||||
end
|
||||
end
|
||||
|
||||
defp cast_id(value) do
|
||||
case Ecto.UUID.cast(value) do
|
||||
|
|
@ -706,6 +741,11 @@ defmodule WhoNeedHelp.Activities do
|
|||
|
||||
defp broadcast(message), do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, message)
|
||||
|
||||
defp broadcast_user(user_id, message),
|
||||
do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "activities:user:#{user_id}", message)
|
||||
|
||||
defp broadcast_activity(id, message),
|
||||
do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "activity:#{id}", message)
|
||||
|
||||
defp member?(%Participant{status: status}), do: status in [:requested, :approved]
|
||||
end
|
||||
|
|
|
|||
|
|
@ -63,7 +63,11 @@ defmodule WhoNeedHelp.Activities.Activity do
|
|||
])
|
||||
|> validate_length(:title, min: 5, max: 120)
|
||||
|> validate_length(:description, min: 10, max: 2_000)
|
||||
|> validate_number(:capacity, greater_than_or_equal_to: 2)
|
||||
|> validate_length(:location_label, max: 255)
|
||||
|> validate_number(:capacity,
|
||||
greater_than_or_equal_to: 2,
|
||||
less_than_or_equal_to: 2_147_483_647
|
||||
)
|
||||
|> validate_acceptance(:safety_confirmed,
|
||||
message: "confirm the safety guidance before publishing"
|
||||
)
|
||||
|
|
@ -93,8 +97,8 @@ defmodule WhoNeedHelp.Activities.Activity do
|
|||
latitude = attrs["latitude"] || attrs[:latitude]
|
||||
longitude = attrs["longitude"] || attrs[:longitude]
|
||||
|
||||
with {lat, ""} <- Float.parse(to_string(latitude)),
|
||||
{lng, ""} <- Float.parse(to_string(longitude)),
|
||||
with {:ok, lat} <- parse_coordinate(latitude),
|
||||
{:ok, lng} <- parse_coordinate(longitude),
|
||||
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
|
||||
put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
|
||||
else
|
||||
|
|
@ -102,6 +106,18 @@ defmodule WhoNeedHelp.Activities.Activity do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_coordinate(value) when is_float(value), do: {:ok, value}
|
||||
defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0}
|
||||
|
||||
defp parse_coordinate(value) when is_binary(value) do
|
||||
case Float.parse(value) do
|
||||
{coordinate, ""} -> {:ok, coordinate}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_coordinate(_value), do: :error
|
||||
|
||||
defp validate_schedule(changeset) do
|
||||
starts_at = get_field(changeset, :starts_at)
|
||||
join_deadline = get_field(changeset, :join_deadline)
|
||||
|
|
|
|||
|
|
@ -303,8 +303,8 @@ defmodule WhoNeedHelp.Catalog do
|
|||
defp valid_structured_value?(%{"type" => "select", "options" => options}, value)
|
||||
when is_list(options) do
|
||||
Enum.any?(options, fn
|
||||
%{"value" => allowed} -> to_string(allowed) == to_string(value)
|
||||
allowed -> to_string(allowed) == to_string(value)
|
||||
%{"value" => allowed} -> same_scalar_value?(allowed, value)
|
||||
allowed -> same_scalar_value?(allowed, value)
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
@ -320,6 +320,13 @@ defmodule WhoNeedHelp.Catalog do
|
|||
|
||||
defp valid_structured_value?(_field, _value), do: false
|
||||
|
||||
defp same_scalar_value?(left, right)
|
||||
when (is_binary(left) or is_atom(left) or is_number(left) or is_boolean(left)) and
|
||||
(is_binary(right) or is_atom(right) or is_number(right) or is_boolean(right)),
|
||||
do: to_string(left) == to_string(right)
|
||||
|
||||
defp same_scalar_value?(_left, _right), do: false
|
||||
|
||||
defp normalize_structured_value(%{"type" => "boolean"}, "true"), do: true
|
||||
defp normalize_structured_value(%{"type" => "boolean"}, "false"), do: false
|
||||
defp normalize_structured_value(_field, value), do: value
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ defmodule WhoNeedHelp.Catalog.Category do
|
|||
|> validate_required([:slug, :names])
|
||||
|> validate_format(:slug, ~r/^[a-z0-9-]+$/)
|
||||
|> validate_length(:slug, max: 80)
|
||||
|> validate_number(:sort_order,
|
||||
greater_than_or_equal_to: -2_147_483_648,
|
||||
less_than_or_equal_to: 2_147_483_647
|
||||
)
|
||||
|> validate_names()
|
||||
|> validate_descriptions()
|
||||
|> validate_structured_fields()
|
||||
|
|
|
|||
|
|
@ -200,62 +200,69 @@ defmodule WhoNeedHelp.Help do
|
|||
end
|
||||
|
||||
def accept_request(%Scope{user: helper}, request_id) do
|
||||
now = DateTime.utc_now(:second)
|
||||
code = handover_code(request_id)
|
||||
|
||||
result =
|
||||
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) do
|
||||
with {:ok, request_id} <- cast_id(request_id),
|
||||
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) do
|
||||
now = DateTime.utc_now(:second)
|
||||
code = handover_code(request_id)
|
||||
|
||||
Repo.transact(fn ->
|
||||
request =
|
||||
HelpRequest
|
||||
|> where([r], r.id == ^request_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
|
||||
:ok = Trust.lock_user_pair(request.requester_id, helper.id)
|
||||
if request do
|
||||
:ok = Trust.lock_user_pair(request.requester_id, helper.id)
|
||||
|
||||
cond do
|
||||
request.requester_id == helper.id ->
|
||||
{:error, :own_request}
|
||||
cond do
|
||||
request.requester_id == helper.id ->
|
||||
{:error, :own_request}
|
||||
|
||||
not is_nil(request.hidden_at) ->
|
||||
{:error, :not_open}
|
||||
not is_nil(request.hidden_at) ->
|
||||
{:error, :not_open}
|
||||
|
||||
Trust.blocked_between?(request.requester_id, helper.id) ->
|
||||
{:error, :blocked}
|
||||
Trust.blocked_between?(request.requester_id, helper.id) ->
|
||||
{:error, :blocked}
|
||||
|
||||
request.status != :open ->
|
||||
{:error, :not_open}
|
||||
request.status != :open ->
|
||||
{:error, :not_open}
|
||||
|
||||
DateTime.compare(request.expires_at, now) != :gt ->
|
||||
{:error, :expired}
|
||||
DateTime.compare(request.expires_at, now) != :gt ->
|
||||
{:error, :expired}
|
||||
|
||||
true ->
|
||||
with {:ok, assignment} <-
|
||||
%Assignment{}
|
||||
|> Assignment.changeset(%{
|
||||
request_id: request.id,
|
||||
helper_id: helper.id,
|
||||
accepted_at: now,
|
||||
handover_code_hash: code_hash(code)
|
||||
})
|
||||
|> Repo.insert(),
|
||||
{:ok, _request} <-
|
||||
request |> Ecto.Changeset.change(status: :matched) |> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(helper.id, "request.accepted", "assignment", assignment.id, %{
|
||||
"request_id" => request.id
|
||||
}),
|
||||
{:ok, _push_job} <-
|
||||
Push.enqueue_request_accepted(
|
||||
assignment.id,
|
||||
request.id,
|
||||
request.requester_id
|
||||
) do
|
||||
{:ok, assignment}
|
||||
end
|
||||
true ->
|
||||
with {:ok, assignment} <-
|
||||
%Assignment{}
|
||||
|> Assignment.changeset(%{
|
||||
request_id: request.id,
|
||||
helper_id: helper.id,
|
||||
accepted_at: now,
|
||||
handover_code_hash: code_hash(code)
|
||||
})
|
||||
|> Repo.insert(),
|
||||
{:ok, _request} <-
|
||||
request |> Ecto.Changeset.change(status: :matched) |> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(helper.id, "request.accepted", "assignment", assignment.id, %{
|
||||
"request_id" => request.id
|
||||
}),
|
||||
{:ok, _push_job} <-
|
||||
Push.enqueue_request_accepted(
|
||||
assignment.id,
|
||||
request.id,
|
||||
request.requester_id
|
||||
) do
|
||||
{:ok, assignment}
|
||||
end
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
:error -> {:error, :not_found}
|
||||
end
|
||||
|
||||
case result do
|
||||
|
|
@ -283,70 +290,86 @@ defmodule WhoNeedHelp.Help do
|
|||
end
|
||||
|
||||
def verify_handover(%Scope{user: user}, assignment_id, code) do
|
||||
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :verify_handover) do
|
||||
with {:ok, assignment_id} <- cast_id(assignment_id),
|
||||
true <- valid_handover_code?(code),
|
||||
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :verify_handover) do
|
||||
Repo.transact(fn ->
|
||||
assignment = locked_assignment(assignment_id)
|
||||
request = Repo.get!(HelpRequest, assignment.request_id)
|
||||
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
|
||||
with %Assignment{} = assignment <- locked_assignment(assignment_id) do
|
||||
request = Repo.get!(HelpRequest, assignment.request_id)
|
||||
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
|
||||
|
||||
cond do
|
||||
user.id != assignment.helper_id ->
|
||||
{:error, :forbidden}
|
||||
cond do
|
||||
user.id != assignment.helper_id ->
|
||||
{:error, :forbidden}
|
||||
|
||||
Trust.blocked_between?(assignment.helper_id, request.requester_id) ->
|
||||
{:error, :blocked}
|
||||
Trust.blocked_between?(assignment.helper_id, request.requester_id) ->
|
||||
{:error, :blocked}
|
||||
|
||||
assignment.status not in [:accepted, :in_progress] or
|
||||
not is_nil(assignment.handover_verified_at) ->
|
||||
{:error, :invalid_transition}
|
||||
assignment.status not in [:accepted, :in_progress] or
|
||||
not is_nil(assignment.handover_verified_at) ->
|
||||
{:error, :invalid_transition}
|
||||
|
||||
not Plug.Crypto.secure_compare(code_hash(code), assignment.handover_code_hash) ->
|
||||
{:error, :invalid_code}
|
||||
not Plug.Crypto.secure_compare(code_hash(code), assignment.handover_code_hash) ->
|
||||
{:error, :invalid_code}
|
||||
|
||||
true ->
|
||||
now = DateTime.utc_now(:second)
|
||||
true ->
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
assignment
|
||||
|> Assignment.changeset(%{handover_verified_at: now})
|
||||
|> maybe_complete(request)
|
||||
|> audit_assignment_transition(user.id, "handover.verified", request.id)
|
||||
assignment
|
||||
|> Assignment.changeset(%{handover_verified_at: now})
|
||||
|> maybe_complete(request)
|
||||
|> audit_assignment_transition(user.id, "handover.verified", request.id)
|
||||
end
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
:error -> {:error, :not_found}
|
||||
false -> {:error, :invalid_code}
|
||||
end
|
||||
|> after_transition()
|
||||
end
|
||||
|
||||
def cancel_request(%Scope{user: user}, request_id) do
|
||||
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :cancel_request) do
|
||||
with {:ok, request_id} <- cast_id(request_id),
|
||||
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :cancel_request) do
|
||||
Repo.transact(fn ->
|
||||
request =
|
||||
HelpRequest
|
||||
|> where([r], r.id == ^request_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
|
||||
if request.requester_id == user.id and request.status in [:open, :matched] do
|
||||
now = DateTime.utc_now(:second)
|
||||
cond do
|
||||
is_nil(request) ->
|
||||
{:error, :not_found}
|
||||
|
||||
assignment =
|
||||
Assignment
|
||||
|> where([assignment], assignment.request_id == ^request.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
request.requester_id == user.id and request.status in [:open, :matched] ->
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
with {:ok, request} <-
|
||||
request
|
||||
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
|
||||
|> Repo.update(),
|
||||
{:ok, _assignment} <- cancel_assignment(assignment),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(user.id, "request.cancelled", "request", request.id) do
|
||||
{:ok, request}
|
||||
end
|
||||
else
|
||||
{:error, :forbidden}
|
||||
assignment =
|
||||
Assignment
|
||||
|> where([assignment], assignment.request_id == ^request.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
with {:ok, request} <-
|
||||
request
|
||||
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
|
||||
|> Repo.update(),
|
||||
{:ok, _assignment} <- cancel_assignment(assignment),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(user.id, "request.cancelled", "request", request.id) do
|
||||
{:ok, request}
|
||||
end
|
||||
|
||||
true ->
|
||||
{:error, :forbidden}
|
||||
end
|
||||
end)
|
||||
else
|
||||
:error -> {:error, :not_found}
|
||||
end
|
||||
|> case do
|
||||
{:ok, request} ->
|
||||
|
|
@ -361,32 +384,39 @@ defmodule WhoNeedHelp.Help do
|
|||
end
|
||||
|
||||
def withdraw_assignment(%Scope{user: user}, assignment_id) do
|
||||
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do
|
||||
with {:ok, assignment_id} <- cast_id(assignment_id),
|
||||
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do
|
||||
Repo.transact(fn ->
|
||||
assignment = locked_assignment(assignment_id)
|
||||
request = Repo.get!(HelpRequest, assignment.request_id)
|
||||
with %Assignment{} = assignment <- locked_assignment(assignment_id) do
|
||||
request = Repo.get!(HelpRequest, assignment.request_id)
|
||||
|
||||
if assignment.helper_id == user.id and assignment.status in [:accepted, :in_progress] do
|
||||
now = DateTime.utc_now(:second)
|
||||
if assignment.helper_id == user.id and
|
||||
assignment.status in [:accepted, :in_progress] do
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
with {:ok, assignment} <-
|
||||
assignment
|
||||
|> Assignment.changeset(%{status: :cancelled})
|
||||
|> Repo.update(),
|
||||
{:ok, _request} <-
|
||||
request
|
||||
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{
|
||||
"request_id" => request.id
|
||||
}) do
|
||||
{:ok, assignment}
|
||||
with {:ok, assignment} <-
|
||||
assignment
|
||||
|> Assignment.changeset(%{status: :cancelled})
|
||||
|> Repo.update(),
|
||||
{:ok, _request} <-
|
||||
request
|
||||
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{
|
||||
"request_id" => request.id
|
||||
}) do
|
||||
{:ok, assignment}
|
||||
end
|
||||
else
|
||||
{:error, :invalid_transition}
|
||||
end
|
||||
else
|
||||
{:error, :invalid_transition}
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
:error -> {:error, :not_found}
|
||||
end
|
||||
|> after_transition()
|
||||
|> case do
|
||||
|
|
@ -469,8 +499,8 @@ defmodule WhoNeedHelp.Help do
|
|||
request.assignment.status in [:accepted, :in_progress] and
|
||||
not Trust.blocked_between?(scope.user.id, request.requester_id)
|
||||
|
||||
if (owner or matched_participant) and
|
||||
request.location_visibility in [:hidden, :exact_for_active_match] do
|
||||
if owner or
|
||||
(matched_participant and request.location_visibility == :exact_for_active_match) do
|
||||
%Geo.Point{coordinates: {lng, lat}} = request.location
|
||||
%{latitude: lat, longitude: lng, exact: true}
|
||||
else
|
||||
|
|
@ -490,53 +520,63 @@ defmodule WhoNeedHelp.Help do
|
|||
end
|
||||
|
||||
defp transition_assignment(user, assignment_id, action) do
|
||||
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), action) do
|
||||
with {:ok, assignment_id} <- cast_id(assignment_id),
|
||||
{:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), action) do
|
||||
Repo.transact(fn ->
|
||||
assignment = locked_assignment(assignment_id)
|
||||
request = Repo.get!(HelpRequest, assignment.request_id)
|
||||
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
|
||||
now = DateTime.utc_now(:second)
|
||||
with %Assignment{} = assignment <- locked_assignment(assignment_id) do
|
||||
request = Repo.get!(HelpRequest, assignment.request_id)
|
||||
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
if Trust.blocked_between?(assignment.helper_id, request.requester_id) do
|
||||
{:error, :blocked}
|
||||
else
|
||||
case {action, assignment.status, user.id} do
|
||||
{:start, :accepted, helper_id} when helper_id == assignment.helper_id ->
|
||||
with {:ok, assignment} <-
|
||||
assignment
|
||||
|> Assignment.changeset(%{status: :in_progress, started_at: now})
|
||||
|> Repo.update(),
|
||||
{:ok, _} <-
|
||||
request |> Ecto.Changeset.change(status: :in_progress) |> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(user.id, "assignment.started", "assignment", assignment.id, %{
|
||||
"request_id" => request.id
|
||||
}) do
|
||||
{:ok, assignment}
|
||||
end
|
||||
|
||||
{:confirm, status, user_id} when status in [:accepted, :in_progress] ->
|
||||
attrs =
|
||||
cond do
|
||||
user_id == assignment.helper_id -> %{helper_confirmed_at: now}
|
||||
user_id == request.requester_id -> %{requester_confirmed_at: now}
|
||||
true -> nil
|
||||
if Trust.blocked_between?(assignment.helper_id, request.requester_id) do
|
||||
{:error, :blocked}
|
||||
else
|
||||
case {action, assignment.status, user.id} do
|
||||
{:start, :accepted, helper_id} when helper_id == assignment.helper_id ->
|
||||
with {:ok, assignment} <-
|
||||
assignment
|
||||
|> Assignment.changeset(%{status: :in_progress, started_at: now})
|
||||
|> Repo.update(),
|
||||
{:ok, _} <-
|
||||
request |> Ecto.Changeset.change(status: :in_progress) |> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
user.id,
|
||||
"assignment.started",
|
||||
"assignment",
|
||||
assignment.id,
|
||||
%{"request_id" => request.id}
|
||||
) do
|
||||
{:ok, assignment}
|
||||
end
|
||||
|
||||
if attrs do
|
||||
assignment
|
||||
|> Assignment.changeset(attrs)
|
||||
|> maybe_complete(request)
|
||||
|> audit_assignment_transition(user.id, "assignment.confirmed", request.id)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
end
|
||||
{:confirm, status, user_id} when status in [:accepted, :in_progress] ->
|
||||
attrs =
|
||||
cond do
|
||||
user_id == assignment.helper_id -> %{helper_confirmed_at: now}
|
||||
user_id == request.requester_id -> %{requester_confirmed_at: now}
|
||||
true -> nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_transition}
|
||||
if attrs do
|
||||
assignment
|
||||
|> Assignment.changeset(attrs)
|
||||
|> maybe_complete(request)
|
||||
|> audit_assignment_transition(user.id, "assignment.confirmed", request.id)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_transition}
|
||||
end
|
||||
end
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
:error -> {:error, :not_found}
|
||||
end
|
||||
|> after_transition()
|
||||
end
|
||||
|
|
@ -571,10 +611,15 @@ defmodule WhoNeedHelp.Help do
|
|||
Assignment
|
||||
|> where([a], a.id == ^id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp code_hash(code), do: :crypto.hash(:sha256, to_string(code))
|
||||
defp valid_handover_code?(code) when is_binary(code),
|
||||
do: Regex.match?(~r/^\d{6}$/, code)
|
||||
|
||||
defp valid_handover_code?(_code), do: false
|
||||
|
||||
defp code_hash(code) when is_binary(code), do: :crypto.hash(:sha256, code)
|
||||
|
||||
defp after_transition({:ok, assignment}) do
|
||||
request = get_request!(assignment.request_id)
|
||||
|
|
@ -648,8 +693,31 @@ defmodule WhoNeedHelp.Help do
|
|||
|
||||
defp maybe_filter(query, _field, value) when value in [nil, ""], do: query
|
||||
|
||||
defp maybe_filter(query, field, value),
|
||||
do: where(query, [request], field(request, ^field) == ^value)
|
||||
defp maybe_filter(query, :category_id, value) do
|
||||
case Ecto.UUID.cast(value) do
|
||||
{:ok, category_id} -> where(query, [request], request.category_id == ^category_id)
|
||||
:error -> where(query, [request], false)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_filter(query, :urgency, value) do
|
||||
case normalize_urgency(value) do
|
||||
{:ok, urgency} -> where(query, [request], request.urgency == ^urgency)
|
||||
:error -> where(query, [request], false)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_urgency(value) when value in [:now, "now"], do: {:ok, :now}
|
||||
defp normalize_urgency(value) when value in [:today, "today"], do: {:ok, :today}
|
||||
defp normalize_urgency(value) when value in [:scheduled, "scheduled"], do: {:ok, :scheduled}
|
||||
defp normalize_urgency(_value), do: :error
|
||||
|
||||
defp cast_id(id) do
|
||||
case Ecto.UUID.cast(id) do
|
||||
{:ok, id} -> {:ok, id}
|
||||
:error -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_structured_data(changeset) do
|
||||
category_id = Ecto.Changeset.get_field(changeset, :category_id)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
|||
|> validate_length(:title, min: 5, max: 120)
|
||||
|> validate_length(:description, min: 10, max: 2_000)
|
||||
|> validate_length(:pickup_instructions, max: 1_000)
|
||||
|> validate_length(:location_label, max: 255)
|
||||
|> validate_acceptance(:safety_confirmed,
|
||||
message: "confirm the safety guidance before publishing"
|
||||
)
|
||||
|
|
@ -80,8 +81,8 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
|||
lat = attrs["latitude"] || attrs[:latitude]
|
||||
lng = attrs["longitude"] || attrs[:longitude]
|
||||
|
||||
with {lat, ""} <- Float.parse(to_string(lat)),
|
||||
{lng, ""} <- Float.parse(to_string(lng)),
|
||||
with {:ok, lat} <- parse_coordinate(lat),
|
||||
{:ok, lng} <- parse_coordinate(lng),
|
||||
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
|
||||
put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
|
||||
else
|
||||
|
|
@ -89,6 +90,18 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_coordinate(value) when is_float(value), do: {:ok, value}
|
||||
defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0}
|
||||
|
||||
defp parse_coordinate(value) when is_binary(value) do
|
||||
case Float.parse(value) do
|
||||
{coordinate, ""} -> {:ok, coordinate}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_coordinate(_value), do: :error
|
||||
|
||||
defp validate_expiry(changeset) do
|
||||
validate_change(changeset, :expires_at, fn :expires_at, value ->
|
||||
if DateTime.after?(value, DateTime.utc_now()),
|
||||
|
|
|
|||
|
|
@ -53,31 +53,35 @@ defmodule WhoNeedHelp.Messaging do
|
|||
Assignment
|
||||
|> where([current], current.id == ^assignment.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.preload(:request)
|
||||
|> Repo.one()
|
||||
|
||||
request = current.request
|
||||
recipient_id = counterpart_id(user.id, current, request)
|
||||
if current do
|
||||
current = Repo.preload(current, :request)
|
||||
request = current.request
|
||||
recipient_id = counterpart_id(user.id, current, request)
|
||||
|
||||
with true <- Help.participant?(scope, current),
|
||||
:ok <- Trust.lock_user_pair(user.id, recipient_id),
|
||||
false <- Trust.blocked_between?(user.id, recipient_id),
|
||||
{:ok, message} <-
|
||||
%Message{assignment_id: current.id, sender_id: user.id}
|
||||
|> Message.changeset(attrs)
|
||||
|> Repo.insert(),
|
||||
{:ok, _push_job} <-
|
||||
Push.enqueue_message_created(
|
||||
message.id,
|
||||
current.id,
|
||||
request.id,
|
||||
recipient_id
|
||||
) do
|
||||
{:ok, message}
|
||||
with true <- Help.participant?(scope, current),
|
||||
:ok <- Trust.lock_user_pair(user.id, recipient_id),
|
||||
false <- Trust.blocked_between?(user.id, recipient_id),
|
||||
{:ok, message} <-
|
||||
%Message{assignment_id: current.id, sender_id: user.id}
|
||||
|> Message.changeset(attrs)
|
||||
|> Repo.insert(),
|
||||
{:ok, _push_job} <-
|
||||
Push.enqueue_message_created(
|
||||
message.id,
|
||||
current.id,
|
||||
request.id,
|
||||
recipient_id
|
||||
) do
|
||||
{:ok, message}
|
||||
else
|
||||
true -> {:error, :blocked}
|
||||
false -> {:error, :forbidden}
|
||||
other -> other
|
||||
end
|
||||
else
|
||||
true -> {:error, :blocked}
|
||||
false -> {:error, :forbidden}
|
||||
other -> other
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,12 +26,24 @@ defmodule WhoNeedHelp.Tracking.Position do
|
|||
lat = attrs["latitude"] || attrs[:latitude]
|
||||
lng = attrs["longitude"] || attrs[:longitude]
|
||||
|
||||
with {lat, ""} <- Float.parse(to_string(lat)),
|
||||
{lng, ""} <- Float.parse(to_string(lng)),
|
||||
with {:ok, lat} <- parse_coordinate(lat),
|
||||
{:ok, lng} <- parse_coordinate(lng),
|
||||
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
|
||||
put_change(changeset, :position, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
|
||||
else
|
||||
_ -> add_error(changeset, :position, "is invalid")
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_coordinate(value) when is_float(value), do: {:ok, value}
|
||||
defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0}
|
||||
|
||||
defp parse_coordinate(value) when is_binary(value) do
|
||||
case Float.parse(value) do
|
||||
{coordinate, ""} -> {:ok, coordinate}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_coordinate(_value), do: :error
|
||||
end
|
||||
|
|
|
|||
|
|
@ -53,47 +53,51 @@ defmodule WhoNeedHelp.Trust do
|
|||
Assignment
|
||||
|> where([current], current.id == ^assignment.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
|
||||
request = Repo.get!(HelpRequest, current.request_id)
|
||||
if current do
|
||||
request = Repo.get!(HelpRequest, current.request_id)
|
||||
|
||||
cond do
|
||||
current.status != :completed ->
|
||||
{:error, :forbidden}
|
||||
cond do
|
||||
current.status != :completed ->
|
||||
{:error, :forbidden}
|
||||
|
||||
user.id not in [current.helper_id, request.requester_id] ->
|
||||
{:error, :forbidden}
|
||||
user.id not in [current.helper_id, request.requester_id] ->
|
||||
{:error, :forbidden}
|
||||
|
||||
true ->
|
||||
reviewee_id =
|
||||
if user.id == current.helper_id,
|
||||
do: request.requester_id,
|
||||
else: current.helper_id
|
||||
true ->
|
||||
reviewee_id =
|
||||
if user.id == current.helper_id,
|
||||
do: request.requester_id,
|
||||
else: current.helper_id
|
||||
|
||||
with {:ok, review} <-
|
||||
%Review{}
|
||||
|> Review.changeset(
|
||||
Map.merge(attrs, %{
|
||||
"assignment_id" => current.id,
|
||||
"reviewer_id" => user.id,
|
||||
"reviewee_id" => reviewee_id
|
||||
})
|
||||
)
|
||||
|> Repo.insert(),
|
||||
reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id),
|
||||
true <- length(reviews) <= 2,
|
||||
revealed? <- length(reviews) == 2,
|
||||
{_count, _rows} <-
|
||||
maybe_reveal_reviews(current.id, revealed?),
|
||||
{:ok, _audit} <-
|
||||
audit(user.id, "review.submitted", "assignment", current.id, %{
|
||||
"revealed" => revealed?
|
||||
}) do
|
||||
{:ok, review}
|
||||
else
|
||||
false -> {:error, :invalid_review_count}
|
||||
other -> other
|
||||
end
|
||||
with {:ok, review} <-
|
||||
%Review{}
|
||||
|> Review.changeset(
|
||||
Map.merge(attrs, %{
|
||||
"assignment_id" => current.id,
|
||||
"reviewer_id" => user.id,
|
||||
"reviewee_id" => reviewee_id
|
||||
})
|
||||
)
|
||||
|> Repo.insert(),
|
||||
reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id),
|
||||
true <- length(reviews) <= 2,
|
||||
revealed? <- length(reviews) == 2,
|
||||
{_count, _rows} <-
|
||||
maybe_reveal_reviews(current.id, revealed?),
|
||||
{:ok, _audit} <-
|
||||
audit(user.id, "review.submitted", "assignment", current.id, %{
|
||||
"revealed" => revealed?
|
||||
}) do
|
||||
{:ok, review}
|
||||
else
|
||||
false -> {:error, :invalid_review_count}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -870,7 +874,7 @@ defmodule WhoNeedHelp.Trust do
|
|||
defp users_by_id([]), do: %{}
|
||||
|
||||
defp users_by_id(ids) do
|
||||
User
|
||||
Accounts.public_user_query()
|
||||
|> where([user], user.id in ^ids)
|
||||
|> Repo.all()
|
||||
|> Map.new(&{&1.id, &1})
|
||||
|
|
@ -1059,51 +1063,59 @@ defmodule WhoNeedHelp.Trust do
|
|||
|
||||
defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id})
|
||||
when is_binary(request_id) do
|
||||
case Repo.get(HelpRequest, request_id) do
|
||||
%HelpRequest{requester_id: requester_id} when requester_id != user.id -> :ok
|
||||
%HelpRequest{} -> {:error, :cannot_report_self}
|
||||
nil -> {:error, :not_found}
|
||||
with {:ok, request_id} <- cast_id(request_id) do
|
||||
case Repo.get(HelpRequest, request_id) do
|
||||
%HelpRequest{requester_id: requester_id} when requester_id != user.id -> :ok
|
||||
%HelpRequest{} -> {:error, :cannot_report_self}
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp authorize_report_target(scope, %{"assignment_id" => assignment_id})
|
||||
when is_binary(assignment_id) do
|
||||
case Repo.get(Assignment, assignment_id) do
|
||||
%Assignment{} = assignment ->
|
||||
if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden}
|
||||
with {:ok, assignment_id} <- cast_id(assignment_id) do
|
||||
case Repo.get(Assignment, assignment_id) do
|
||||
%Assignment{} = assignment ->
|
||||
if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden}
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp authorize_report_target(scope, %{"message_id" => message_id}) when is_binary(message_id) do
|
||||
case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do
|
||||
%Message{sender_id: sender_id, assignment: assignment} ->
|
||||
cond do
|
||||
sender_id == scope.user.id -> {:error, :cannot_report_self}
|
||||
Help.participant?(scope, assignment) -> :ok
|
||||
true -> {:error, :forbidden}
|
||||
end
|
||||
with {:ok, message_id} <- cast_id(message_id) do
|
||||
case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do
|
||||
%Message{sender_id: sender_id, assignment: assignment} ->
|
||||
cond do
|
||||
sender_id == scope.user.id -> {:error, :cannot_report_self}
|
||||
Help.participant?(scope, assignment) -> :ok
|
||||
true -> {:error, :forbidden}
|
||||
end
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp authorize_report_target(%Scope{user: user}, %{"activity_id" => activity_id})
|
||||
when is_binary(activity_id) do
|
||||
case Repo.get(Activity, activity_id) do
|
||||
%Activity{creator_id: creator_id} when creator_id == user.id ->
|
||||
{:error, :cannot_report_self}
|
||||
with {:ok, activity_id} <- cast_id(activity_id) do
|
||||
case Repo.get(Activity, activity_id) do
|
||||
%Activity{creator_id: creator_id} when creator_id == user.id ->
|
||||
{:error, :cannot_report_self}
|
||||
|
||||
%Activity{} = activity ->
|
||||
if is_nil(activity.hidden_at) or activity_participant?(activity.id, user.id),
|
||||
do: :ok,
|
||||
else: {:error, :not_found}
|
||||
%Activity{} = activity ->
|
||||
if is_nil(activity.hidden_at) or activity_participant?(activity.id, user.id),
|
||||
do: :ok,
|
||||
else: {:error, :not_found}
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1112,17 +1124,19 @@ defmodule WhoNeedHelp.Trust do
|
|||
%{"activity_message_id" => message_id}
|
||||
)
|
||||
when is_binary(message_id) do
|
||||
case Repo.get(ActivityMessage, message_id) do
|
||||
%ActivityMessage{sender_id: sender_id} when sender_id == user.id ->
|
||||
{:error, :cannot_report_self}
|
||||
with {:ok, message_id} <- cast_id(message_id) do
|
||||
case Repo.get(ActivityMessage, message_id) do
|
||||
%ActivityMessage{sender_id: sender_id} when sender_id == user.id ->
|
||||
{:error, :cannot_report_self}
|
||||
|
||||
%ActivityMessage{activity_id: activity_id} ->
|
||||
if activity_participant?(activity_id, user.id),
|
||||
do: :ok,
|
||||
else: {:error, :forbidden}
|
||||
%ActivityMessage{activity_id: activity_id} ->
|
||||
if activity_participant?(activity_id, user.id),
|
||||
do: :ok,
|
||||
else: {:error, :forbidden}
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1195,18 +1209,16 @@ defmodule WhoNeedHelp.Trust do
|
|||
)
|
||||
|> where(
|
||||
[assignment, request],
|
||||
(assignment.helper_id == ^first_user_id and request.requester_id == ^second_user_id) or
|
||||
(assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id)
|
||||
assignment.status in [:accepted, :in_progress] and
|
||||
((assignment.helper_id == ^first_user_id and
|
||||
request.requester_id == ^second_user_id) or
|
||||
(assignment.helper_id == ^second_user_id and
|
||||
request.requester_id == ^first_user_id))
|
||||
)
|
||||
|> select([assignment, request], {assignment.id, request.id, assignment.status})
|
||||
|> select([assignment, request], {assignment.id, request.id})
|
||||
|> Repo.all()
|
||||
|
||||
active_assignment_ids =
|
||||
assignments
|
||||
|> Enum.filter(fn {_assignment_id, _request_id, status} ->
|
||||
status in [:accepted, :in_progress]
|
||||
end)
|
||||
|> Enum.map(&elem(&1, 0))
|
||||
active_assignment_ids = Enum.map(assignments, &elem(&1, 0))
|
||||
|
||||
active_sessions =
|
||||
if active_assignment_ids == [] do
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ defmodule WhoNeedHelp.Trust.RateLimiter do
|
|||
|
||||
defp policy(action) do
|
||||
policies = Application.get_env(:who_need_help, :rate_limit_policies, %{})
|
||||
raw = Map.get(policies, action) || Map.get(policies, String.to_existing_atom(action))
|
||||
|
||||
raw =
|
||||
if is_map(policies) do
|
||||
Map.get(policies, action) || Map.get(policies, String.to_existing_atom(action))
|
||||
end
|
||||
|
||||
case raw do
|
||||
%{limit: limit, window_seconds: window}
|
||||
|
|
|
|||
|
|
@ -16,13 +16,15 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
|||
with {:ok, expired} <- expire_available(now, 0),
|
||||
{:ok, cleanup} <- Tracking.cleanup_finished_sessions() do
|
||||
{pruned_buckets, _} = RateLimiter.prune_expired()
|
||||
{pruned_user_tokens, _} = WhoNeedHelp.Accounts.delete_expired_user_tokens(now)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
expired: expired,
|
||||
tracking_positions_deleted: cleanup.positions_deleted,
|
||||
tracking_sessions_ended: cleanup.sessions_ended,
|
||||
rate_limit_buckets_pruned: pruned_buckets
|
||||
rate_limit_buckets_pruned: pruned_buckets,
|
||||
user_tokens_pruned: pruned_user_tokens
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ defmodule WhoNeedHelpWeb.CoreComponents do
|
|||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> assign_new(:value, fn -> safe_form_value(field.value) end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
|
|
@ -289,7 +289,7 @@ defmodule WhoNeedHelpWeb.CoreComponents do
|
|||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
value={normalized_input_value(@type, @value)}
|
||||
class={[
|
||||
@class || "w-full input",
|
||||
@errors != [] && (@error_class || "input-error")
|
||||
|
|
@ -302,6 +302,24 @@ defmodule WhoNeedHelpWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
defp normalized_input_value("password", _value), do: nil
|
||||
|
||||
defp normalized_input_value(type, value),
|
||||
do: Phoenix.HTML.Form.normalize_value(type, value)
|
||||
|
||||
defp safe_form_value(value) when is_list(value) do
|
||||
if Enum.all?(value, &safe_form_value?/1),
|
||||
do: value,
|
||||
else: nil
|
||||
end
|
||||
|
||||
defp safe_form_value(value) do
|
||||
if Phoenix.HTML.Safe.impl_for(value), do: value, else: nil
|
||||
end
|
||||
|
||||
defp safe_form_value?(value) when is_list(value), do: Enum.all?(value, &safe_form_value?/1)
|
||||
defp safe_form_value?(value), do: not is_nil(Phoenix.HTML.Safe.impl_for(value))
|
||||
|
||||
# Helper used by inputs to generate form errors
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ defmodule WhoNeedHelpWeb.MetricsController do
|
|||
|
||||
conn
|
||||
|> put_resp_header("cache-control", "no-store")
|
||||
|> put_resp_header("x-wnh-node", to_string(node()))
|
||||
|> put_resp_content_type(@content_type)
|
||||
|> send_resp(:ok, body)
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,40 +1,44 @@
|
|||
defmodule WhoNeedHelpWeb.UserRegistrationController do
|
||||
use WhoNeedHelpWeb, :controller
|
||||
|
||||
require Logger
|
||||
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.Accounts.User
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
|
||||
plug :put_no_store
|
||||
|
||||
def new(conn, _params) do
|
||||
changeset = Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
|
||||
render(conn, :new, changeset: changeset)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
email_scope = user_params["email"] |> to_string() |> String.trim() |> String.downcase()
|
||||
def create(conn, %{"user" => user_params}) when is_map(user_params) do
|
||||
user_params = normalize_registration_params(user_params)
|
||||
email_scope = normalize_email_scope(user_params["email"])
|
||||
|
||||
with {:ok, _limit} <- RateLimiter.check(:registration_email, email_scope),
|
||||
result <- Accounts.register_user(user_params) do
|
||||
case result do
|
||||
{:ok, user} ->
|
||||
{:ok, _} =
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext(
|
||||
"An email was sent to %{email}, please access it to confirm your account.",
|
||||
email: user.email
|
||||
)
|
||||
)
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
deliver_registration_instructions(conn, user)
|
||||
registration_response(conn)
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, :new, changeset: changeset)
|
||||
if unique_email_error?(changeset) do
|
||||
case Accounts.get_user_by_email(user_params["email"]) do
|
||||
%User{moderation_status: status} = user when status != :suspended ->
|
||||
deliver_registration_instructions(conn, user)
|
||||
|
||||
_missing_or_suspended ->
|
||||
:ok
|
||||
end
|
||||
|
||||
registration_response(conn)
|
||||
else
|
||||
render(conn, :new, changeset: changeset)
|
||||
end
|
||||
end
|
||||
else
|
||||
{:error, :rate_limited} ->
|
||||
|
|
@ -49,4 +53,76 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
|
|||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> put_flash(:error, gettext("The registration form is invalid."))
|
||||
|> render(:new,
|
||||
changeset: Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
|
||||
)
|
||||
end
|
||||
|
||||
defp normalize_email_scope(email) when is_binary(email),
|
||||
do: email |> String.trim() |> String.downcase()
|
||||
|
||||
defp normalize_email_scope(_email), do: ""
|
||||
|
||||
defp normalize_registration_params(params) do
|
||||
params
|
||||
|> normalize_binary_param("email")
|
||||
|> normalize_binary_param("display_name")
|
||||
|> normalize_binary_param("locale")
|
||||
|> normalize_boolean_param("terms_accepted")
|
||||
end
|
||||
|
||||
defp normalize_binary_param(params, key) do
|
||||
case Map.fetch(params, key) do
|
||||
{:ok, value} when is_binary(value) -> params
|
||||
{:ok, _invalid} -> Map.put(params, key, "")
|
||||
:error -> params
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_boolean_param(params, key) do
|
||||
case Map.fetch(params, key) do
|
||||
{:ok, value} when is_binary(value) or is_boolean(value) -> params
|
||||
{:ok, _invalid} -> Map.put(params, key, false)
|
||||
:error -> params
|
||||
end
|
||||
end
|
||||
|
||||
defp deliver_registration_instructions(conn, user) do
|
||||
case Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&"#{url(conn, ~p"/users/log-in")}#token=#{URI.encode_www_form(&1)}"
|
||||
) do
|
||||
{:ok, _email} -> :ok
|
||||
{:error, _reason} -> Logger.warning("Unable to send registration login instructions")
|
||||
end
|
||||
end
|
||||
|
||||
defp registration_response(conn) do
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext(
|
||||
"If this address can be registered or signed in, login instructions will arrive shortly."
|
||||
)
|
||||
)
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
|
||||
defp unique_email_error?(%Ecto.Changeset{} = changeset) do
|
||||
Enum.any?(changeset.errors, fn
|
||||
{:email, {_message, options}} ->
|
||||
Keyword.get(options, :constraint) == :unique or
|
||||
Keyword.get(options, :validation) == :unsafe_unique
|
||||
|
||||
_other ->
|
||||
false
|
||||
end)
|
||||
end
|
||||
|
||||
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
defmodule WhoNeedHelpWeb.UserSessionController do
|
||||
use WhoNeedHelpWeb, :controller
|
||||
|
||||
require Logger
|
||||
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
alias WhoNeedHelpWeb.UserAuth
|
||||
|
||||
plug :assign_magic_link_form
|
||||
plug :put_no_store
|
||||
|
||||
def new(conn, _params) do
|
||||
email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
|
||||
form = Phoenix.Component.to_form(%{"email" => email}, as: "user")
|
||||
|
|
@ -13,7 +18,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
end
|
||||
|
||||
# magic link login
|
||||
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
|
||||
def create(conn, %{"user" => %{"token" => token} = user_params} = params)
|
||||
when is_binary(token) do
|
||||
info =
|
||||
case params do
|
||||
%{"_action" => "confirmed"} -> gettext("User confirmed successfully.")
|
||||
|
|
@ -34,7 +40,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
end
|
||||
|
||||
# email + password login
|
||||
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
|
||||
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params})
|
||||
when is_binary(email) and is_binary(password) do
|
||||
email_scope = email |> String.trim() |> String.downcase()
|
||||
|
||||
case RateLimiter.check(:password_login_email, email_scope) do
|
||||
|
|
@ -56,17 +63,23 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
end
|
||||
|
||||
# magic link request
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
def create(conn, %{"user" => %{"email" => email}}) when is_binary(email) do
|
||||
email_scope = email |> String.trim() |> String.downcase()
|
||||
|
||||
case RateLimiter.check(:magic_link_email, email_scope) do
|
||||
{:ok, _limit} ->
|
||||
case Accounts.get_user_by_email(email) do
|
||||
%Accounts.User{moderation_status: status} = user when status != :suspended ->
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
case Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&"#{url(~p"/users/log-in")}#token=#{URI.encode_www_form(&1)}"
|
||||
) do
|
||||
{:ok, _email} ->
|
||||
:ok
|
||||
|
||||
{:error, _reason} ->
|
||||
Logger.warning("Unable to send requested login instructions")
|
||||
end
|
||||
|
||||
_missing_or_suspended ->
|
||||
:ok
|
||||
|
|
@ -92,19 +105,11 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
end
|
||||
end
|
||||
|
||||
def confirm(conn, %{"token" => token}) do
|
||||
if user = Accounts.get_user_by_magic_link_token(token) do
|
||||
form = Phoenix.Component.to_form(%{"token" => token}, as: "user")
|
||||
|
||||
conn
|
||||
|> assign(:user, user)
|
||||
|> assign(:form, form)
|
||||
|> render(:confirm)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, gettext("Magic link is invalid or it has expired."))
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
def create(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> put_flash(:error, gettext("The sign-in form is invalid."))
|
||||
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
|
|
@ -119,4 +124,14 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
|> put_flash(:error, gettext("Invalid email or password"))
|
||||
|> render(:new, form: Phoenix.Component.to_form(user_params, as: "user"))
|
||||
end
|
||||
|
||||
defp assign_magic_link_form(conn, _opts) do
|
||||
assign(
|
||||
conn,
|
||||
:magic_link_form,
|
||||
Phoenix.Component.to_form(%{"token" => ""}, as: "user")
|
||||
)
|
||||
end
|
||||
|
||||
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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,47 +30,81 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label={gettext("Email")}
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
<.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=""
|
||||
/>
|
||||
<.button class="btn btn-primary w-full">
|
||||
{gettext("Log in with email")} <span aria-hidden="true">→</span>
|
||||
<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 class="divider">{gettext("or")}</div>
|
||||
<div id="standard-login-options">
|
||||
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label={gettext("Email")}
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<.button class="btn btn-primary w-full">
|
||||
{gettext("Log in with email")} <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label={gettext("Email")}
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={f[:password]}
|
||||
type="password"
|
||||
label={gettext("Password")}
|
||||
autocomplete="current-password"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
|
||||
{gettext("Log in and stay logged in")} <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
<.button class="btn btn-primary btn-soft w-full mt-2">
|
||||
{gettext("Log in only this time")}
|
||||
</.button>
|
||||
</.form>
|
||||
<div class="divider">{gettext("or")}</div>
|
||||
|
||||
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label={gettext("Email")}
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={f[:password]}
|
||||
type="password"
|
||||
label={gettext("Password")}
|
||||
autocomplete="current-password"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
|
||||
{gettext("Log in and stay logged in")} <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
<.button class="btn btn-primary btn-soft w-full mt-2">
|
||||
{gettext("Log in only this time")}
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -1,44 +1,54 @@
|
|||
defmodule WhoNeedHelpWeb.UserSettingsController do
|
||||
use WhoNeedHelpWeb, :controller
|
||||
|
||||
require Logger
|
||||
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
alias WhoNeedHelpWeb.UserAuth
|
||||
|
||||
import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2]
|
||||
|
||||
plug :require_sudo_mode
|
||||
plug :assign_email_and_password_changesets
|
||||
plug :require_sudo_mode when action in [:edit, :update]
|
||||
plug :assign_email_and_password_changesets when action in [:edit, :update]
|
||||
|
||||
def edit(conn, _params) do
|
||||
render(conn, :edit)
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "update_email"} = params) do
|
||||
%{"user" => user_params} = params
|
||||
def update(conn, %{"action" => "update_email", "user" => user_params})
|
||||
when is_map(user_params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
case Accounts.change_user_email(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
Accounts.deliver_user_update_email_instructions(
|
||||
Ecto.Changeset.apply_action!(changeset, :insert),
|
||||
user.email,
|
||||
&url(~p"/users/settings/confirm-email/#{&1}")
|
||||
)
|
||||
new_email =
|
||||
changeset
|
||||
|> Ecto.Changeset.get_change(:email, "")
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext("A link to confirm your email change has been sent to the new address.")
|
||||
)
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
case RateLimiter.check(:email_change_email, new_email) do
|
||||
{:ok, _limit} ->
|
||||
deliver_email_change_instructions(conn, user, changeset)
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
conn
|
||||
|> put_status(:too_many_requests)
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Too many email-change requests in the configured time window.")
|
||||
)
|
||||
|> render(:edit)
|
||||
end
|
||||
|
||||
changeset ->
|
||||
render(conn, :edit, email_changeset: %{changeset | action: :insert})
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "update_password"} = params) do
|
||||
%{"user" => user_params} = params
|
||||
def update(conn, %{"action" => "update_password", "user" => user_params})
|
||||
when is_map(user_params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
case Accounts.update_user_password(user, user_params) do
|
||||
|
|
@ -56,7 +66,18 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
|||
end
|
||||
end
|
||||
|
||||
def confirm_email(conn, %{"token" => token}) do
|
||||
def update(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> put_flash(:error, gettext("The settings form is invalid."))
|
||||
|> render(:edit)
|
||||
end
|
||||
|
||||
def confirm_email_page(conn, _params) do
|
||||
render(conn, :confirm_email)
|
||||
end
|
||||
|
||||
def confirm_email(conn, %{"token" => token}) when is_binary(token) do
|
||||
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
|
||||
{:ok, _user} ->
|
||||
conn
|
||||
|
|
@ -70,6 +91,12 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
|||
end
|
||||
end
|
||||
|
||||
def confirm_email(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:error, gettext("Email change link is invalid or it has expired."))
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
end
|
||||
|
||||
defp assign_email_and_password_changesets(conn, _opts) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
|
|
@ -77,4 +104,30 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
|||
|> assign(:email_changeset, Accounts.change_user_email(user))
|
||||
|> assign(:password_changeset, Accounts.change_user_password(user))
|
||||
end
|
||||
|
||||
defp deliver_email_change_instructions(conn, user, changeset) do
|
||||
case Accounts.deliver_user_update_email_instructions(
|
||||
Ecto.Changeset.apply_action!(changeset, :insert),
|
||||
user.email,
|
||||
&"#{url(~p"/users/settings/confirm-email")}#token=#{URI.encode_www_form(&1)}"
|
||||
) do
|
||||
{:ok, _email} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext("A link to confirm your email change has been sent to the new address.")
|
||||
)
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, _reason} ->
|
||||
Logger.warning("Unable to send email-change instructions")
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("The confirmation email could not be sent. Please try again.")
|
||||
)
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
key: "_who_need_help_key",
|
||||
signing_salt: "XN+IwK3w",
|
||||
same_site: "Lax"
|
||||
same_site: "Lax",
|
||||
secure: Application.compile_env(:who_need_help, :secure_cookies, false)
|
||||
]
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
|
|
@ -45,8 +46,7 @@ defmodule WhoNeedHelpWeb.Endpoint do
|
|||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
plug Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
parsers: [:urlencoded, :json],
|
||||
json_decoder: Phoenix.json_library()
|
||||
|
||||
plug Plug.MethodOverride
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.{Activities, Catalog}
|
||||
alias WhoNeedHelp.{Activities, Catalog, Pagination}
|
||||
alias WhoNeedHelp.Activities.Activity
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket), do: Activities.subscribe()
|
||||
if connected?(socket) do
|
||||
Activities.subscribe()
|
||||
Activities.subscribe_user(socket.assigns.current_scope)
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -18,8 +21,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
def handle_info({event, activity}, socket)
|
||||
when event in [:activity_created, :activity_updated] do
|
||||
user_id = socket.assigns.current_scope.user.id
|
||||
already_mine? = Enum.any?(socket.assigns.my_activities, &(&1.id == activity.id))
|
||||
|
||||
activities =
|
||||
{activities, activities_cursor} =
|
||||
update_entry(
|
||||
socket.assigns.activities,
|
||||
activity,
|
||||
|
|
@ -28,24 +32,44 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
activity,
|
||||
socket.assigns.filters
|
||||
),
|
||||
:asc
|
||||
:asc,
|
||||
socket.assigns.activities_cursor
|
||||
)
|
||||
|
||||
my_activities =
|
||||
{my_activities, my_activities_cursor} =
|
||||
update_entry(
|
||||
socket.assigns.my_activities,
|
||||
activity,
|
||||
Activities.member_activity?(activity, user_id),
|
||||
:desc
|
||||
already_mine? or activity.creator_id == user_id,
|
||||
:desc,
|
||||
socket.assigns.my_activities_cursor
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:activities, activities)
|
||||
|> assign(:activities_cursor, activities_cursor)
|
||||
|> assign(:my_activities, my_activities)
|
||||
|> assign(:my_activities_cursor, my_activities_cursor)
|
||||
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))}
|
||||
end
|
||||
|
||||
def handle_info({:my_activity_updated, activity, member?}, socket) do
|
||||
{my_activities, my_activities_cursor} =
|
||||
update_entry(
|
||||
socket.assigns.my_activities,
|
||||
activity,
|
||||
member?,
|
||||
:desc,
|
||||
socket.assigns.my_activities_cursor
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:my_activities, my_activities)
|
||||
|> assign(:my_activities_cursor, my_activities_cursor)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", %{"filters" => filters}, socket) do
|
||||
{:noreply, socket |> assign(:filters, filters) |> load()}
|
||||
|
|
@ -106,10 +130,23 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
|
||||
end
|
||||
|
||||
defp update_entry(entries, activity, visible?, direction) do
|
||||
defp update_entry(entries, activity, visible?, direction, cursor) do
|
||||
window_limit = max(length(entries), Pagination.limit([]))
|
||||
entries = Enum.reject(entries, &(&1.id == activity.id))
|
||||
entries = if visible?, do: [activity | entries], else: entries
|
||||
Enum.sort_by(entries, &{&1.starts_at, &1.id}, direction)
|
||||
entries = Enum.sort_by(entries, &{&1.starts_at, &1.id}, direction)
|
||||
overflow? = length(entries) > window_limit
|
||||
entries = Enum.take(entries, window_limit)
|
||||
|
||||
cursor =
|
||||
if overflow? do
|
||||
activity = List.last(entries)
|
||||
Pagination.encode(activity.starts_at, activity.id)
|
||||
else
|
||||
cursor
|
||||
end
|
||||
|
||||
{entries, cursor}
|
||||
end
|
||||
|
||||
defp marker(activity) do
|
||||
|
|
@ -195,6 +232,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
|
||||
<.link
|
||||
:for={activity <- @activities}
|
||||
id={"open-activity-#{activity.id}"}
|
||||
navigate={~p"/activities/#{activity.id}"}
|
||||
class="block rounded-3xl border border-base-300 bg-base-100 p-6 transition hover:border-info/50"
|
||||
>
|
||||
|
|
@ -255,6 +293,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<.link
|
||||
:for={activity <- @my_activities}
|
||||
id={"my-activity-#{activity.id}"}
|
||||
navigate={~p"/activities/#{activity.id}"}
|
||||
class="rounded-2xl bg-base-200 p-4"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
defp option_value(option), do: option
|
||||
|
||||
defp structured_value(form, key) do
|
||||
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
|
||||
case Map.get(form.params, "structured_data") do
|
||||
structured_data when is_map(structured_data) -> Map.get(structured_data, key)
|
||||
_invalid_or_missing -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp error_message(:account_not_eligible),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.{Activities, Trust}
|
||||
alias WhoNeedHelp.{Activities, Pagination, Trust}
|
||||
|
||||
@initial_message_window 50
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
|
|
@ -20,9 +22,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
|
||||
@impl true
|
||||
def handle_info({:activity_message, message}, socket) do
|
||||
activity = socket.assigns.activity
|
||||
messages = append_message(activity.messages, message)
|
||||
{:noreply, assign(socket, :activity, %{activity | messages: messages})}
|
||||
{:noreply, put_realtime_message(socket, message)}
|
||||
end
|
||||
|
||||
def handle_info({event, _payload}, socket)
|
||||
|
|
@ -80,13 +80,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
params
|
||||
) do
|
||||
{:ok, message} ->
|
||||
activity = socket.assigns.activity
|
||||
|
||||
{:noreply,
|
||||
assign(socket, :activity, %{
|
||||
activity
|
||||
| messages: append_message(activity.messages, message)
|
||||
})}
|
||||
{:noreply, put_realtime_message(socket, message)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, error_message(reason))}
|
||||
|
|
@ -108,7 +102,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:activity, %{activity | messages: older ++ activity.messages})
|
||||
|> assign(:messages_cursor, page.next_cursor)}
|
||||
|> assign(:messages_cursor, page.next_cursor)
|
||||
|> update(:message_window_limit, &(&1 + length(older)))}
|
||||
end
|
||||
|
||||
def handle_event("report", %{"report" => params}, socket) do
|
||||
|
|
@ -212,6 +207,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
|> assign(:page_title, activity.title)
|
||||
|> assign(:activity, activity)
|
||||
|> assign(:messages_cursor, messages.next_cursor)
|
||||
|> assign(:message_window_limit, max(length(messages.entries), @initial_message_window))
|
||||
|> assign(:viewer_participant, viewer_participant)
|
||||
|> assign(:organizer?, organizer?)
|
||||
|> assign(:approved?, approved?)
|
||||
|
|
@ -246,6 +242,23 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
if Enum.any?(messages, &(&1.id == message.id)), do: messages, else: messages ++ [message]
|
||||
end
|
||||
|
||||
defp put_realtime_message(socket, message) do
|
||||
activity = socket.assigns.activity
|
||||
messages = append_message(activity.messages, message)
|
||||
window_limit = socket.assigns.message_window_limit
|
||||
|
||||
if length(messages) > window_limit do
|
||||
messages = Enum.take(messages, -window_limit)
|
||||
oldest = hd(messages)
|
||||
|
||||
socket
|
||||
|> assign(:activity, %{activity | messages: messages})
|
||||
|> assign(:messages_cursor, Pagination.encode(oldest.inserted_at, oldest.id))
|
||||
else
|
||||
assign(socket, :activity, %{activity | messages: messages})
|
||||
end
|
||||
end
|
||||
|
||||
defp structured_details(activity, locale) do
|
||||
activity.category
|
||||
|> WhoNeedHelp.Catalog.structured_fields()
|
||||
|
|
@ -389,7 +402,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
:for={identity <- @activity.creator.social_identities}
|
||||
href={identity.profile_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
rel="noopener noreferrer nofollow ugc"
|
||||
class={[
|
||||
"badge badge-outline",
|
||||
identity.verified_at && "badge-success",
|
||||
|
|
@ -540,7 +553,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
:for={identity <- participant.user.social_identities}
|
||||
href={identity.profile_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
rel="noopener noreferrer nofollow ugc"
|
||||
class={[
|
||||
"badge badge-xs badge-outline",
|
||||
identity.verified_at && "badge-success",
|
||||
|
|
@ -599,7 +612,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
:for={identity <- participant.user.social_identities}
|
||||
href={identity.profile_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
rel="noopener noreferrer nofollow ugc"
|
||||
class={[
|
||||
"badge badge-xs badge-outline",
|
||||
identity.verified_at && "badge-success",
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
}
|
||||
|> Map.reject(fn {_locale, description} -> description in [nil, ""] end)
|
||||
|
||||
with {:ok, structured_fields} <- Jason.decode(params["structured_fields_json"] || "{}"),
|
||||
with {:ok, structured_fields} <- decode_structured_fields(params["structured_fields_json"]),
|
||||
true <- is_map(structured_fields) do
|
||||
attrs = %{
|
||||
"slug" => params["slug"],
|
||||
|
|
@ -190,6 +190,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
{:noreply, put_flash(socket, :error, error_message(reason))}
|
||||
end
|
||||
|
||||
defp decode_structured_fields(value) when value in [nil, ""], do: {:ok, %{}}
|
||||
defp decode_structured_fields(value) when is_binary(value), do: Jason.decode(value)
|
||||
defp decode_structured_fields(_value), do: {:error, :invalid_type}
|
||||
|
||||
defp load(socket) do
|
||||
reports = Trust.paginate_reports(socket.assigns.current_scope)
|
||||
signals = Trust.paginate_abuse_signals(socket.assigns.current_scope)
|
||||
|
|
|
|||
|
|
@ -44,15 +44,22 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
def handle_event("add-social", %{"social_identity" => params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
case Accounts.add_social_identity(user, params) do
|
||||
{:ok, _identity} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_social_identities(user)
|
||||
|> put_flash(:info, gettext("Social link added as unverified."))}
|
||||
|
||||
{:error, changeset} ->
|
||||
with {:ok, _limit} <- Trust.authorize_action(socket.assigns.current_scope, :add_social),
|
||||
{:ok, _identity} <- Accounts.add_social_identity(user, params) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_social_identities(user)
|
||||
|> put_flash(:info, gettext("Social link added as unverified."))}
|
||||
else
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :social_form, to_form(changeset, as: :social_identity))}
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("Too many actions in the configured time window."))}
|
||||
|
||||
{:error, :account_not_eligible} ->
|
||||
{:noreply, put_flash(socket, :error, gettext("This account cannot perform that action."))}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -273,7 +280,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
<a
|
||||
href={identity.profile_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
rel="noopener noreferrer nofollow ugc"
|
||||
class="link mt-1 block truncate text-sm"
|
||||
>
|
||||
{identity.handle || identity.profile_url}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.{Help, Trust}
|
||||
alias WhoNeedHelp.{Help, Pagination, Trust}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
@ -17,7 +17,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
requests =
|
||||
{requests, requests_cursor} =
|
||||
update_entry(
|
||||
socket.assigns.requests,
|
||||
request,
|
||||
|
|
@ -26,21 +26,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
request,
|
||||
socket.assigns.filters
|
||||
),
|
||||
:open
|
||||
:open,
|
||||
socket.assigns.requests_cursor
|
||||
)
|
||||
|
||||
my_requests =
|
||||
{my_requests, my_requests_cursor} =
|
||||
update_entry(
|
||||
socket.assigns.my_requests,
|
||||
request,
|
||||
request.requester_id == user.id,
|
||||
:mine
|
||||
:mine,
|
||||
socket.assigns.my_requests_cursor
|
||||
)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:requests, requests)
|
||||
|> assign(:requests_cursor, requests_cursor)
|
||||
|> assign(:my_requests, my_requests)
|
||||
|> assign(:my_requests_cursor, my_requests_cursor)
|
||||
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))
|
||||
|> maybe_refresh_reputation(request, user.id)
|
||||
|
||||
|
|
@ -106,7 +110,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
|
||||
end
|
||||
|
||||
defp update_entry(entries, request, visible?, order) do
|
||||
defp update_entry(entries, request, visible?, order, cursor) do
|
||||
window_limit = max(length(entries), Pagination.limit([]))
|
||||
entries = Enum.reject(entries, &(&1.id == request.id))
|
||||
|
||||
entries =
|
||||
|
|
@ -114,10 +119,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
do: [request | entries],
|
||||
else: entries
|
||||
|
||||
case order do
|
||||
:open -> Enum.sort_by(entries, &{&1.expires_at, &1.id}, :asc)
|
||||
:mine -> Enum.sort_by(entries, &{&1.inserted_at, &1.id}, :desc)
|
||||
end
|
||||
entries =
|
||||
case order do
|
||||
:open -> Enum.sort_by(entries, &{&1.expires_at, &1.id}, :asc)
|
||||
:mine -> Enum.sort_by(entries, &{&1.inserted_at, &1.id}, :desc)
|
||||
end
|
||||
|
||||
overflow? = length(entries) > window_limit
|
||||
entries = Enum.take(entries, window_limit)
|
||||
|
||||
cursor =
|
||||
if overflow? do
|
||||
request = List.last(entries)
|
||||
timestamp = if order == :open, do: request.expires_at, else: request.inserted_at
|
||||
Pagination.encode(timestamp, request.id)
|
||||
else
|
||||
cursor
|
||||
end
|
||||
|
||||
{entries, cursor}
|
||||
end
|
||||
|
||||
defp maybe_refresh_reputation(socket, request, user_id) do
|
||||
|
|
@ -240,6 +260,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
|
||||
<.link
|
||||
:for={request <- @requests}
|
||||
id={"open-request-#{request.id}"}
|
||||
navigate={~p"/requests/#{request.id}"}
|
||||
class="help-card block rounded-3xl border border-base-300 bg-base-100 p-6"
|
||||
>
|
||||
|
|
@ -322,6 +343,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<.link
|
||||
:for={request <- @my_requests}
|
||||
id={"my-request-#{request.id}"}
|
||||
navigate={~p"/requests/#{request.id}"}
|
||||
class="rounded-2xl bg-base-200 p-4"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,10 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
defp option_value(option), do: option
|
||||
|
||||
defp structured_value(form, key) do
|
||||
form.params |> Map.get("structured_data", %{}) |> Map.get(key)
|
||||
case Map.get(form.params, "structured_data") do
|
||||
structured_data when is_map(structured_data) -> Map.get(structured_data, key)
|
||||
_invalid_or_missing -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp category_description(nil, _locale), do: nil
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.{Help, Messaging, Tracking, Trust}
|
||||
alias WhoNeedHelp.{Help, Messaging, Pagination, Tracking, Trust}
|
||||
alias WhoNeedHelpWeb.Presence
|
||||
|
||||
@e2e_routes Application.compile_env(:who_need_help, :e2e_routes, false)
|
||||
@initial_message_window 50
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
|
|
@ -76,7 +77,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
end
|
||||
|
||||
def handle_info({:new_message, message}, socket) do
|
||||
{:noreply, assign(socket, :messages, append_message(socket.assigns.messages, message))}
|
||||
{:noreply, put_realtime_message(socket, message)}
|
||||
end
|
||||
|
||||
def handle_info({:position_updated, user_id, position, evidence}, socket) do
|
||||
|
|
@ -155,7 +156,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
{:ok, message} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:messages, append_message(socket.assigns.messages, message))
|
||||
|> put_realtime_message(message)
|
||||
|> push_event("reset-message-form", %{id: "message-form"})}
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
@ -177,7 +178,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:messages, older ++ socket.assigns.messages)
|
||||
|> assign(:messages_cursor, page.next_cursor)}
|
||||
|> assign(:messages_cursor, page.next_cursor)
|
||||
|> update(:message_window_limit, &(&1 + length(older)))}
|
||||
end
|
||||
|
||||
def handle_event("start-tracking", _, socket) do
|
||||
|
|
@ -427,6 +429,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
)
|
||||
|> assign(:messages, messages_page.entries)
|
||||
|> assign(:messages_cursor, messages_page.next_cursor)
|
||||
|> assign(:message_window_limit, max(length(messages_page.entries), @initial_message_window))
|
||||
|> assign(:report_message_id, nil)
|
||||
|> assign(:positions, positions)
|
||||
|> assign(:tracking_active, tracking_active)
|
||||
|
|
@ -446,6 +449,22 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
if Enum.any?(messages, &(&1.id == message.id)), do: messages, else: messages ++ [message]
|
||||
end
|
||||
|
||||
defp put_realtime_message(socket, message) do
|
||||
messages = append_message(socket.assigns.messages, message)
|
||||
window_limit = socket.assigns.message_window_limit
|
||||
|
||||
if length(messages) > window_limit do
|
||||
messages = Enum.take(messages, -window_limit)
|
||||
oldest = hd(messages)
|
||||
|
||||
socket
|
||||
|> assign(:messages, messages)
|
||||
|> assign(:messages_cursor, Pagination.encode(oldest.inserted_at, oldest.id))
|
||||
else
|
||||
assign(socket, :messages, messages)
|
||||
end
|
||||
end
|
||||
|
||||
defp report_form do
|
||||
to_form(%{"reason" => "dangerous_request", "details" => ""}, as: :report)
|
||||
end
|
||||
|
|
@ -570,8 +589,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
latitude: point.latitude,
|
||||
longitude: point.longitude,
|
||||
exact: true,
|
||||
title: gettext("Shared live location"),
|
||||
location: gettext("Active match only")
|
||||
title: gettext("Shared location"),
|
||||
location:
|
||||
gettext(
|
||||
"Last update: %{time}",
|
||||
time: Calendar.strftime(point.captured_at, "%d.%m.%Y, %H:%M:%S UTC")
|
||||
)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
|
@ -704,7 +727,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
:for={identity <- @request.requester.social_identities}
|
||||
href={identity.profile_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
rel="noopener noreferrer nofollow ugc"
|
||||
class="badge badge-warning badge-outline"
|
||||
>
|
||||
{identity.provider} · {if identity.verified_at,
|
||||
|
|
@ -901,7 +924,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
:for={identity <- @assignment.helper.social_identities}
|
||||
href={identity.profile_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
rel="noopener noreferrer nofollow ugc"
|
||||
class="badge badge-warning badge-outline"
|
||||
>
|
||||
{identity.provider} · {if identity.verified_at,
|
||||
|
|
@ -982,7 +1005,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
}
|
||||
href={@assignment.helper.tip_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
rel="noopener noreferrer nofollow ugc"
|
||||
class="btn btn-outline w-full"
|
||||
>
|
||||
{gettext("Optional thank-you link")}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,19 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
|
||||
import WhoNeedHelpWeb.UserAuth
|
||||
|
||||
@secure_browser_headers %{
|
||||
"content-security-policy" =>
|
||||
"default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'",
|
||||
"permissions-policy" => "geolocation=(self), camera=(), microphone=(), payment=(), usb=()"
|
||||
}
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {WhoNeedHelpWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :put_secure_browser_headers, @secure_browser_headers
|
||||
plug :put_content_security_policy
|
||||
plug :fetch_current_scope_for_user
|
||||
plug :put_authenticated_cache_policy
|
||||
|
|
@ -24,7 +30,7 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
plug :accepts, ["json"]
|
||||
plug :fetch_session
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :put_secure_browser_headers, @secure_browser_headers
|
||||
plug :put_content_security_policy
|
||||
plug :fetch_current_scope_for_user
|
||||
plug :put_authenticated_cache_policy
|
||||
|
|
@ -104,7 +110,8 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
|
||||
get "/users/settings", UserSettingsController, :edit
|
||||
put "/users/settings", UserSettingsController, :update
|
||||
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
|
||||
get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page
|
||||
post "/users/settings/confirm-email", UserSettingsController, :confirm_email
|
||||
get "/auth/social/:provider", SocialOAuthController, :request
|
||||
get "/auth/social/:provider/callback", SocialOAuthController, :callback
|
||||
end
|
||||
|
|
@ -135,7 +142,6 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
pipe_through [:browser]
|
||||
|
||||
get "/users/log-in", UserSessionController, :new
|
||||
get "/users/log-in/:token", UserSessionController, :confirm
|
||||
post "/users/log-in", UserSessionController, :create
|
||||
delete "/users/log-out", UserSessionController, :delete
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
@remember_me_options [
|
||||
sign: true,
|
||||
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
|
||||
same_site: "Lax"
|
||||
same_site: "Lax",
|
||||
secure: Application.compile_env(:who_need_help, :secure_cookies, false)
|
||||
]
|
||||
|
||||
# How old the session token should be before a new one is issued. When a request is made
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ defmodule WhoNeedHelpWeb.ValidationMessages do
|
|||
dgettext_noop("errors", "must be in the future"),
|
||||
dgettext_noop("errors", "select an activity category"),
|
||||
dgettext_noop("errors", "must be on or before the start time"),
|
||||
dgettext_noop("errors", "must be a complete http(s) URL"),
|
||||
dgettext_noop("errors", "must be a complete HTTPS URL"),
|
||||
dgettext_noop("errors", "must be a full HTTPS URL"),
|
||||
dgettext_noop("errors", "must be a GitHub profile URL"),
|
||||
dgettext_noop("errors", "you must confirm that you are 18+ and accept the rules"),
|
||||
dgettext_noop("errors", "must have the @ sign and no spaces"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -116,12 +116,12 @@ msgstr ""
|
|||
msgid "contains an invalid field definition"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "did not change"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#: lib/who_need_help_web/validation_messages.ex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "does not match password"
|
||||
msgstr ""
|
||||
|
|
@ -131,16 +131,11 @@ msgstr ""
|
|||
msgid "field keys must be unique"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a GitHub profile URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete http(s) URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:12
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be an object with a fields array"
|
||||
|
|
@ -166,7 +161,7 @@ msgstr ""
|
|||
msgid "must contain non-empty localized descriptions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must have the @ sign and no spaces"
|
||||
msgstr ""
|
||||
|
|
@ -186,7 +181,17 @@ msgstr ""
|
|||
msgid "select exactly one report target"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "you must confirm that you are 18+ and accept the rules"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete HTTPS URL"
|
||||
msgstr "must be a complete HTTPS URL"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a full HTTPS URL"
|
||||
msgstr "must be a full HTTPS URL"
|
||||
|
|
|
|||
|
|
@ -113,12 +113,12 @@ msgstr ""
|
|||
msgid "contains an invalid field definition"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "did not change"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#: lib/who_need_help_web/validation_messages.ex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "does not match password"
|
||||
msgstr ""
|
||||
|
|
@ -128,16 +128,11 @@ msgstr ""
|
|||
msgid "field keys must be unique"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a GitHub profile URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete http(s) URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:12
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be an object with a fields array"
|
||||
|
|
@ -163,7 +158,7 @@ msgstr ""
|
|||
msgid "must contain non-empty localized descriptions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must have the @ sign and no spaces"
|
||||
msgstr ""
|
||||
|
|
@ -183,7 +178,17 @@ msgstr ""
|
|||
msgid "select exactly one report target"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "you must confirm that you are 18+ and accept the rules"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete HTTPS URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a full HTTPS URL"
|
||||
msgstr ""
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -115,12 +115,12 @@ msgstr "должно быть равно %{number}"
|
|||
msgid "contains an invalid field definition"
|
||||
msgstr "содержит неверное определение поля"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "did not change"
|
||||
msgstr "не изменилось"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#: lib/who_need_help_web/validation_messages.ex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "does not match password"
|
||||
msgstr "не совпадает с паролем"
|
||||
|
|
@ -130,16 +130,11 @@ msgstr "не совпадает с паролем"
|
|||
msgid "field keys must be unique"
|
||||
msgstr "ключи полей должны быть уникальными"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a GitHub profile URL"
|
||||
msgstr "должно быть URL профиля GitHub"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete http(s) URL"
|
||||
msgstr "должно быть полным URL с http(s)"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:12
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be an object with a fields array"
|
||||
|
|
@ -165,7 +160,7 @@ msgstr "должно содержать хотя бы одно непустое
|
|||
msgid "must contain non-empty localized descriptions"
|
||||
msgstr "должно содержать непустые локализованные описания"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must have the @ sign and no spaces"
|
||||
msgstr "должно содержать знак @ и не содержать пробелов"
|
||||
|
|
@ -185,7 +180,17 @@ msgstr "выберите категорию активности"
|
|||
msgid "select exactly one report target"
|
||||
msgstr "выберите ровно один объект жалобы"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "you must confirm that you are 18+ and accept the rules"
|
||||
msgstr "подтвердите, что вам исполнилось 18 лет и вы принимаете правила"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete HTTPS URL"
|
||||
msgstr "должно быть полным HTTPS URL"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a full HTTPS URL"
|
||||
msgstr "должно быть полным HTTPS URL"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -115,12 +115,12 @@ msgstr "має дорівнювати %{number}"
|
|||
msgid "contains an invalid field definition"
|
||||
msgstr "містить неправильне визначення поля"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "did not change"
|
||||
msgstr "не змінилося"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:23
|
||||
#: lib/who_need_help_web/validation_messages.ex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "does not match password"
|
||||
msgstr "не збігається з паролем"
|
||||
|
|
@ -130,16 +130,11 @@ msgstr "не збігається з паролем"
|
|||
msgid "field keys must be unique"
|
||||
msgstr "ключі полів мають бути унікальними"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a GitHub profile URL"
|
||||
msgstr "має бути URL-адресою профілю GitHub"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete http(s) URL"
|
||||
msgstr "має бути повною URL-адресою з http(s)"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:12
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be an object with a fields array"
|
||||
|
|
@ -165,7 +160,7 @@ msgstr "має містити щонайменше одну непорожню
|
|||
msgid "must contain non-empty localized descriptions"
|
||||
msgstr "має містити непорожні локалізовані описи"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#: lib/who_need_help_web/validation_messages.ex:22
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must have the @ sign and no spaces"
|
||||
msgstr "має містити знак @ і не містити пробілів"
|
||||
|
|
@ -185,7 +180,17 @@ msgstr "виберіть категорію активності"
|
|||
msgid "select exactly one report target"
|
||||
msgstr "виберіть рівно один об’єкт скарги"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:20
|
||||
#: lib/who_need_help_web/validation_messages.ex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "you must confirm that you are 18+ and accept the rules"
|
||||
msgstr "підтвердьте, що вам виповнилося 18 років і ви приймаєте правила"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a complete HTTPS URL"
|
||||
msgstr "має бути повною HTTPS URL-адресою"
|
||||
|
||||
#: lib/who_need_help_web/validation_messages.ex:19
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "must be a full HTTPS URL"
|
||||
msgstr "має бути повною HTTPS URL-адресою"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ ANDROID_ENV="$ROOT/.env"
|
|||
TEST_ENV="$ROOT/.env.android-test"
|
||||
run_id=$(date -u +%Y%m%d%H%M%S)-$$
|
||||
android_api=${WNH_ANDROID_TEST_API:-37.0}
|
||||
# 1G is measured against the complete API 30/34/37 suite. It can still be
|
||||
# overridden for a future test that intentionally stores more device data.
|
||||
# 1G is measured against the API 30/34/37 suite and is also exercised by the
|
||||
# API 24 minimum-SDK probe. It can still be overridden for a future test that
|
||||
# intentionally stores more device data.
|
||||
android_data_partition_size=${WNH_ANDROID_TEST_DATA_PARTITION_SIZE:-1G}
|
||||
|
||||
if ! printf '%s\n' "$android_data_partition_size" |
|
||||
|
|
@ -17,6 +18,9 @@ if ! printf '%s\n' "$android_data_partition_size" |
|
|||
fi
|
||||
|
||||
case "$android_api" in
|
||||
24)
|
||||
android_system_image="system-images/android-24/google_apis/x86_64"
|
||||
;;
|
||||
30)
|
||||
android_system_image="system-images/android-30/google_apis/x86_64"
|
||||
;;
|
||||
|
|
@ -27,7 +31,7 @@ case "$android_api" in
|
|||
android_system_image="system-images/android-37.0/google_apis_ps16k/x86_64"
|
||||
;;
|
||||
*)
|
||||
echo "WNH_ANDROID_TEST_API must be one of: 30, 34, 37.0." >&2
|
||||
echo "WNH_ANDROID_TEST_API must be one of: 24, 30, 34, 37.0." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -100,6 +104,12 @@ cleanup() {
|
|||
docker exec "$container" adb logcat -d > "$output/logcat.txt" 2>&1 || true
|
||||
docker exec "$container" adb shell dumpsys activity services \
|
||||
org.whoneedhelp.mobile.debug > "$output/services.txt" 2>&1 || true
|
||||
docker exec "$container" adb shell dumpsys notification --noredact \
|
||||
> "$output/notifications.txt" 2>&1 || true
|
||||
docker exec "$container" adb shell uiautomator dump /sdcard/window.xml \
|
||||
> "$output/window-dump.txt" 2>&1 || true
|
||||
docker exec "$container" adb shell cat /sdcard/window.xml \
|
||||
> "$output/window.xml" 2>&1 || true
|
||||
docker logs "$container" > "$output/emulator.log" 2>&1 || true
|
||||
fi
|
||||
|
||||
|
|
@ -151,7 +161,11 @@ docker exec "$container" adb shell input keyevent 82
|
|||
docker exec "$container" adb shell settings put global window_animation_scale 0
|
||||
docker exec "$container" adb shell settings put global transition_animation_scale 0
|
||||
docker exec "$container" adb shell settings put global animator_duration_scale 0
|
||||
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 install -r \
|
||||
/opt/who-need-help/who-need-help-debug.apk
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
set -eu
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
matrix=${WNH_ANDROID_TEST_API_MATRIX:-"30 34 37.0"}
|
||||
matrix=${WNH_ANDROID_TEST_API_MATRIX:-"24 30 34 37.0"}
|
||||
|
||||
for api in $matrix; do
|
||||
case "$api" in
|
||||
30|34|37.0) ;;
|
||||
24|30|34|37.0) ;;
|
||||
*)
|
||||
echo "WNH_ANDROID_TEST_API_MATRIX contains unsupported API: $api" >&2
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \
|
|||
LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS \
|
||||
LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS \
|
||||
LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \
|
||||
HTTP_PORT POSTGRES_DB; do
|
||||
HTTP_PORT POSTGRES_DB METRICS_TOKEN; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "$name is missing from .env.load" >&2
|
||||
exit 1
|
||||
|
|
@ -184,6 +184,7 @@ sample_readiness() {
|
|||
observed_at=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
|
||||
body_file="$output_dir/readiness-body.$$"
|
||||
error_file="$output_dir/readiness-error.$$"
|
||||
metrics_headers_file="$output_dir/metrics-headers.$$"
|
||||
set +e
|
||||
status=$(
|
||||
curl --silent --show-error \
|
||||
|
|
@ -206,15 +207,55 @@ sample_readiness() {
|
|||
body=
|
||||
fi
|
||||
|
||||
set +e
|
||||
metrics_status=$(
|
||||
curl --silent --show-error \
|
||||
--max-time "$LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS" \
|
||||
--header "Host: $LOAD_HOST" \
|
||||
--header "Authorization: Bearer $METRICS_TOKEN" \
|
||||
--dump-header "$metrics_headers_file" \
|
||||
--output /dev/null \
|
||||
--write-out '%{http_code}' \
|
||||
"http://localhost:$HTTP_PORT/metrics" 2>>"$error_file"
|
||||
)
|
||||
metrics_curl_status=$?
|
||||
set -e
|
||||
|
||||
if [[ "$metrics_curl_status" -ne 0 ]]; then
|
||||
metrics_status=000
|
||||
fi
|
||||
|
||||
node=$(
|
||||
awk '
|
||||
BEGIN {IGNORECASE = 1}
|
||||
/^x-wnh-node:/ {
|
||||
sub(/^[^:]+:[[:space:]]*/, "")
|
||||
sub(/\r$/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$metrics_headers_file" 2>/dev/null || true
|
||||
)
|
||||
|
||||
error=$(tr -d '\n' <"$error_file")
|
||||
unlink "$body_file" 2>/dev/null || true
|
||||
unlink "$error_file" 2>/dev/null || true
|
||||
unlink "$metrics_headers_file" 2>/dev/null || true
|
||||
jq -cn \
|
||||
--arg observed_at "$observed_at" \
|
||||
--arg status "$status" \
|
||||
--arg metrics_status "$metrics_status" \
|
||||
--arg node "$node" \
|
||||
--arg body "$body" \
|
||||
--arg error "$error" \
|
||||
'{observed_at: $observed_at, status: $status, body: $body, error: $error}' \
|
||||
'{
|
||||
observed_at: $observed_at,
|
||||
status: $status,
|
||||
metrics_status: $metrics_status,
|
||||
node: $node,
|
||||
body: $body,
|
||||
error: $error
|
||||
}' \
|
||||
>>"$probe_log"
|
||||
sleep "$LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS"
|
||||
done
|
||||
|
|
@ -338,7 +379,7 @@ mapfile -t original_web_ids < <(service_ids web)
|
|||
for container_id in "${original_web_ids[@]}"; do
|
||||
assert_scope "$container_id" web
|
||||
{
|
||||
docker stop --time 30 "$container_id"
|
||||
docker stop --timeout 30 "$container_id"
|
||||
docker rm "$container_id"
|
||||
"${compose[@]}" up -d --no-deps --scale "web=$LOAD_WEB_REPLICAS" web
|
||||
} >>"$output_dir/web-replacements.txt"
|
||||
|
|
@ -350,7 +391,7 @@ mapfile -t original_worker_ids < <(service_ids worker)
|
|||
for container_id in "${original_worker_ids[@]}"; do
|
||||
assert_scope "$container_id" worker
|
||||
{
|
||||
docker stop --time 30 "$container_id"
|
||||
docker stop --timeout 30 "$container_id"
|
||||
docker rm "$container_id"
|
||||
"${compose[@]}" up -d --no-deps --scale "worker=$LOAD_WORKER_REPLICAS" worker
|
||||
} >>"$output_dir/worker-replacements.txt"
|
||||
|
|
@ -438,8 +479,8 @@ probe_pid=
|
|||
|
||||
jq -s '{
|
||||
samples: length,
|
||||
failures: (map(select(.status != "200")) | length),
|
||||
nodes: (map(.body | fromjson? | .node) | map(select(. != null)) | unique)
|
||||
failures: (map(select(.status != "200" or .metrics_status != "200")) | length),
|
||||
nodes: (map(.node) | map(select(. != null and . != "")) | unique)
|
||||
}' "$probe_log" >"$output_dir/readiness-summary.json"
|
||||
|
||||
if ! jq -e '.samples > 0 and .failures == 0 and (.nodes | length) >= 2' \
|
||||
|
|
|
|||
|
|
@ -213,7 +213,16 @@ echo "Linting the Helm chart"
|
|||
"$ROOT/deploy/helm/who-need-help"
|
||||
|
||||
echo "Scanning only tracked and non-ignored source files"
|
||||
git ls-files --cached --others --exclude-standard -z >"$scan_list"
|
||||
# The single-quoted program must expand $path inside the child shell.
|
||||
# shellcheck disable=SC2016
|
||||
git ls-files --cached --others --exclude-standard -z |
|
||||
xargs -0 -r sh -c '
|
||||
for path do
|
||||
if [ -f "$path" ]; then
|
||||
printf "%s\0" "$path"
|
||||
fi
|
||||
done
|
||||
' sh >"$scan_list"
|
||||
tar --null --no-recursion --files-from="$scan_list" --create --file="$scan_tar"
|
||||
tar --extract --file="$scan_tar" --directory "$scan_dir"
|
||||
"$ROOT/.tools/bin/helm" template who-need-help \
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
alias WhoNeedHelp.Accounts
|
||||
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
alias WhoNeedHelp.Accounts.{User, UserToken}
|
||||
alias WhoNeedHelp.Accounts.{SocialIdentity, User, UserToken}
|
||||
|
||||
describe "get_user_by_email/1" do
|
||||
test "does not return the user if the email does not exist" do
|
||||
|
|
@ -14,6 +14,7 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
test "returns the user if the email exists" do
|
||||
%{id: id} = user = user_fixture()
|
||||
assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
|
||||
assert %User{id: ^id} = Accounts.get_user_by_email(" #{String.upcase(user.email)} ")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -32,6 +33,12 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
|
||||
assert %User{id: ^id} =
|
||||
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
|
||||
|
||||
assert %User{id: ^id} =
|
||||
Accounts.get_user_by_email_and_password(
|
||||
" #{String.upcase(user.email)} ",
|
||||
valid_user_password()
|
||||
)
|
||||
end
|
||||
|
||||
test "does not authenticate a suspended user with a valid password" do
|
||||
|
|
@ -140,6 +147,67 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "profile URL validation" do
|
||||
test "accepts only encrypted thank-you and social profile URLs" do
|
||||
user = user_fixture()
|
||||
|
||||
profile_attrs = %{
|
||||
"display_name" => user.display_name,
|
||||
"locale" => user.locale,
|
||||
"location_visibility" => user.location_visibility,
|
||||
"direct_message_policy" => user.direct_message_policy
|
||||
}
|
||||
|
||||
assert User.profile_changeset(
|
||||
user,
|
||||
Map.put(profile_attrs, "tip_url", "https://example.com/thank-you")
|
||||
).valid?
|
||||
|
||||
refute User.profile_changeset(
|
||||
user,
|
||||
Map.put(profile_attrs, "tip_url", "http://example.com/thank-you")
|
||||
).valid?
|
||||
|
||||
social = %SocialIdentity{user_id: Ecto.UUID.generate()}
|
||||
social_attrs = %{"provider" => "other", "profile_url" => "https://example.com/profile"}
|
||||
|
||||
assert SocialIdentity.changeset(social, social_attrs).valid?
|
||||
|
||||
refute SocialIdentity.changeset(
|
||||
social,
|
||||
Map.put(social_attrs, "profile_url", "http://example.com/profile")
|
||||
).valid?
|
||||
end
|
||||
|
||||
test "rejects a thank-you URL that cannot fit its database column" do
|
||||
user = user_fixture()
|
||||
|
||||
changeset =
|
||||
User.profile_changeset(user, %{
|
||||
"display_name" => user.display_name,
|
||||
"locale" => user.locale,
|
||||
"location_visibility" => user.location_visibility,
|
||||
"direct_message_policy" => user.direct_message_policy,
|
||||
"tip_url" => "https://example.com/#{String.duplicate("x", 240)}"
|
||||
})
|
||||
|
||||
assert "should be at most 255 character(s)" in errors_on(changeset).tip_url
|
||||
end
|
||||
|
||||
test "rejects a social URL that cannot fit its database column" do
|
||||
changeset =
|
||||
SocialIdentity.changeset(
|
||||
%SocialIdentity{user_id: Ecto.UUID.generate()},
|
||||
%{
|
||||
"provider" => "other",
|
||||
"profile_url" => "https://example.com/#{String.duplicate("x", 240)}"
|
||||
}
|
||||
)
|
||||
|
||||
assert "should be at most 255 character(s)" in errors_on(changeset).profile_url
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_user_email/3" do
|
||||
test "returns a user changeset" do
|
||||
assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
|
||||
|
|
@ -364,6 +432,10 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
end
|
||||
|
||||
describe "login_user_by_magic_link/1" do
|
||||
test "returns not found for a syntactically invalid token" do
|
||||
assert {:error, :not_found} = Accounts.login_user_by_magic_link("%not-base64%")
|
||||
end
|
||||
|
||||
test "confirms user and expires tokens" do
|
||||
user = unconfirmed_user_fixture()
|
||||
refute user.confirmed_at
|
||||
|
|
@ -384,6 +456,24 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
|
||||
end
|
||||
|
||||
test "concurrent requests consume an unconfirmed user's token exactly once" do
|
||||
user = unconfirmed_user_fixture()
|
||||
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
|
||||
|
||||
results =
|
||||
1..2
|
||||
|> Task.async_stream(
|
||||
fn _ -> Accounts.login_user_by_magic_link(encoded_token) end,
|
||||
max_concurrency: 2,
|
||||
ordered: false
|
||||
)
|
||||
|> Enum.map(fn {:ok, result} -> result end)
|
||||
|
||||
assert Enum.count(results, &match?({:ok, {%User{}, _tokens}}, &1)) == 1
|
||||
assert Enum.count(results, &(&1 == {:error, :not_found})) == 1
|
||||
assert Accounts.get_user!(user.id).confirmed_at
|
||||
end
|
||||
|
||||
test "rejects a suspended user and consumes the magic link" do
|
||||
user = user_fixture()
|
||||
|
||||
|
|
@ -419,6 +509,54 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "delete_expired_user_tokens/1" do
|
||||
test "prunes each token context only after its configured validity" do
|
||||
user = user_fixture()
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
expired_login =
|
||||
Repo.insert!(%UserToken{
|
||||
user_id: user.id,
|
||||
token: :crypto.strong_rand_bytes(32),
|
||||
context: "login",
|
||||
sent_to: user.email,
|
||||
inserted_at: DateTime.add(now, -16, :minute)
|
||||
})
|
||||
|
||||
active_login =
|
||||
Repo.insert!(%UserToken{
|
||||
user_id: user.id,
|
||||
token: :crypto.strong_rand_bytes(32),
|
||||
context: "login",
|
||||
sent_to: user.email,
|
||||
inserted_at: DateTime.add(now, -14, :minute)
|
||||
})
|
||||
|
||||
expired_change =
|
||||
Repo.insert!(%UserToken{
|
||||
user_id: user.id,
|
||||
token: :crypto.strong_rand_bytes(32),
|
||||
context: "change:#{user.email}",
|
||||
sent_to: unique_user_email(),
|
||||
inserted_at: DateTime.add(now, -8, :day)
|
||||
})
|
||||
|
||||
expired_session =
|
||||
Repo.insert!(%UserToken{
|
||||
user_id: user.id,
|
||||
token: :crypto.strong_rand_bytes(32),
|
||||
context: "session",
|
||||
inserted_at: DateTime.add(now, -15, :day)
|
||||
})
|
||||
|
||||
assert {3, _} = Accounts.delete_expired_user_tokens(now)
|
||||
refute Repo.get(UserToken, expired_login.id)
|
||||
refute Repo.get(UserToken, expired_change.id)
|
||||
refute Repo.get(UserToken, expired_session.id)
|
||||
assert Repo.get(UserToken, active_login.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_user_by_session_token/1 moderation boundary" do
|
||||
test "never authenticates a suspended user even if a token still exists" do
|
||||
user = user_fixture()
|
||||
|
|
|
|||
|
|
@ -88,12 +88,113 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
Activities.request_to_join(context.outsider_scope, activity.id)
|
||||
end
|
||||
|
||||
test "invalid participant identifiers are rejected", context do
|
||||
test "global activity events carry only the bounded index projection", context do
|
||||
:ok = Activities.subscribe()
|
||||
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
||||
|
||||
assert_receive {:activity_created, created}
|
||||
assert created.id == activity.id
|
||||
assert created.approved_participant_count == 1
|
||||
assert %Ecto.Association.NotLoaded{} = created.participants
|
||||
assert %Ecto.Association.NotLoaded{} = created.messages
|
||||
assert %Ecto.Association.NotLoaded{} = created.creator.social_identities
|
||||
|
||||
:ok = Activities.subscribe_user(context.participant_scope)
|
||||
{:ok, _request} = Activities.request_to_join(context.participant_scope, activity.id)
|
||||
|
||||
assert_receive {:activity_updated, updated}
|
||||
assert updated.id == activity.id
|
||||
assert updated.approved_participant_count == 1
|
||||
assert %Ecto.Association.NotLoaded{} = updated.participants
|
||||
assert %Ecto.Association.NotLoaded{} = updated.messages
|
||||
assert_receive {:my_activity_updated, participant_update, true}
|
||||
assert participant_update.id == activity.id
|
||||
end
|
||||
|
||||
test "invalid and missing client identifiers are rejected without raising", context do
|
||||
assert {:error, :not_found} =
|
||||
Activities.approve_participant(context.organizer_scope, "not-a-uuid")
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.decline_participant(context.organizer_scope, "not-a-uuid")
|
||||
|
||||
missing_id = Ecto.UUID.generate()
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.approve_participant(context.organizer_scope, missing_id)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.decline_participant(context.organizer_scope, missing_id)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.request_to_join(context.participant_scope, missing_id)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.send_message(context.participant_scope, missing_id, %{"body" => "No"})
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.cancel_activity(context.organizer_scope, missing_id)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.complete_activity(context.organizer_scope, missing_id)
|
||||
|
||||
for invalid_id <- ["not-a-uuid", missing_id] do
|
||||
assert {:error, :not_found} =
|
||||
Activities.leave_activity(context.participant_scope, invalid_id)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.request_to_join(context.participant_scope, invalid_id)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.send_message(context.participant_scope, invalid_id, %{"body" => "No"})
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.cancel_activity(context.organizer_scope, invalid_id)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.complete_activity(context.organizer_scope, invalid_id)
|
||||
end
|
||||
end
|
||||
|
||||
test "activity location labels are validated before the database write", context do
|
||||
assert {:error, changeset} =
|
||||
Activities.create_activity(
|
||||
context.organizer_scope,
|
||||
Map.put(context.attrs, "location_label", String.duplicate("x", 256))
|
||||
)
|
||||
|
||||
assert "should be at most 255 character(s)" in errors_on(changeset).location_label
|
||||
end
|
||||
|
||||
test "activity capacity outside the PostgreSQL integer range is rejected before insert",
|
||||
context do
|
||||
assert {:error, changeset} =
|
||||
Activities.create_activity(
|
||||
context.organizer_scope,
|
||||
Map.put(context.attrs, "capacity", 2_147_483_648)
|
||||
)
|
||||
|
||||
assert "must be less than or equal to 2147483647" in errors_on(changeset).capacity
|
||||
end
|
||||
|
||||
test "forged coordinate types become validation errors instead of raising", context do
|
||||
assert {:error, changeset} =
|
||||
Activities.create_activity(
|
||||
context.organizer_scope,
|
||||
Map.put(context.attrs, "latitude", %{"nested" => "value"})
|
||||
)
|
||||
|
||||
assert "select a valid location" in errors_on(changeset).location
|
||||
end
|
||||
|
||||
test "forged category filters return no activities instead of raising", context do
|
||||
assert {:ok, _activity} =
|
||||
Activities.create_activity(context.organizer_scope, context.attrs)
|
||||
|
||||
assert [] ==
|
||||
Activities.list_open_activities(context.participant_scope, %{
|
||||
"category_id" => "not-a-uuid"
|
||||
})
|
||||
end
|
||||
|
||||
test "unapproved viewers receive no private chat or pending participant data", context do
|
||||
|
|
|
|||
|
|
@ -79,6 +79,61 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
assert {:error, :not_found} = Catalog.unvote(context.requester_scope, "not-a-uuid")
|
||||
end
|
||||
|
||||
test "invalid request workflow identifiers and handover codes are rejected", context do
|
||||
missing_id = Ecto.UUID.generate()
|
||||
|
||||
for id <- ["not-a-uuid", missing_id] do
|
||||
assert {:error, :not_found} = Help.accept_request(context.helper_scope, id)
|
||||
assert {:error, :not_found} = Help.cancel_request(context.requester_scope, id)
|
||||
assert {:error, :not_found} = Help.start_assignment(context.helper_scope, id)
|
||||
assert {:error, :not_found} = Help.confirm_completion(context.helper_scope, id)
|
||||
assert {:error, :not_found} = Help.withdraw_assignment(context.helper_scope, id)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Help.verify_handover(context.helper_scope, id, "123456")
|
||||
end
|
||||
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
|
||||
for code <- [%{"nested" => "123456"}, "12345", "1234567", "abcdef"] do
|
||||
assert {:error, :invalid_code} =
|
||||
Help.verify_handover(context.helper_scope, assignment.id, code)
|
||||
end
|
||||
end
|
||||
|
||||
test "request location labels are validated before the database write", context do
|
||||
assert {:error, changeset} =
|
||||
Help.create_request(
|
||||
context.requester_scope,
|
||||
Map.put(context.request_attrs, "location_label", String.duplicate("x", 256))
|
||||
)
|
||||
|
||||
assert "should be at most 255 character(s)" in errors_on(changeset).location_label
|
||||
end
|
||||
|
||||
test "forged request coordinate types become validation errors instead of raising", context do
|
||||
assert {:error, changeset} =
|
||||
Help.create_request(
|
||||
context.requester_scope,
|
||||
Map.put(context.request_attrs, "longitude", %{"nested" => "value"})
|
||||
)
|
||||
|
||||
assert "select a valid location" in errors_on(changeset).location
|
||||
end
|
||||
|
||||
test "forged filters return no requests instead of raising", context do
|
||||
assert [] ==
|
||||
Help.list_open_requests(context.helper_scope, %{
|
||||
"category_id" => "not-a-uuid"
|
||||
})
|
||||
|
||||
assert [] ==
|
||||
Help.list_open_requests(context.helper_scope, %{
|
||||
"urgency" => "impossible"
|
||||
})
|
||||
end
|
||||
|
||||
test "chat is durable and only visible to match participants", context do
|
||||
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
|
|
@ -211,6 +266,21 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
assert oldest.next_cursor == nil
|
||||
end
|
||||
|
||||
test "chat and review return not found for a stale assignment reference", context do
|
||||
stale_assignment = %WhoNeedHelp.Help.Assignment{id: Ecto.UUID.generate()}
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Messaging.send_message(context.helper_scope, stale_assignment, %{
|
||||
"body" => "This assignment no longer exists"
|
||||
})
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Trust.submit_review(context.helper_scope, stale_assignment, %{
|
||||
"rating" => 5,
|
||||
"comment" => "This assignment no longer exists"
|
||||
})
|
||||
end
|
||||
|
||||
test "stopping tracking deletes the exact current position", context do
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
|
|
@ -229,6 +299,21 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
refute Repo.get(Position, position.id)
|
||||
end
|
||||
|
||||
test "forged tracking coordinate types become changeset errors instead of raising", context do
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
{:ok, _session} = Tracking.start_session(context.helper_scope, assignment)
|
||||
|
||||
assert {:error, changeset} =
|
||||
Tracking.update_position(context.helper_scope, assignment, %{
|
||||
"latitude" => %{"nested" => "value"},
|
||||
"longitude" => 30.52,
|
||||
"accuracy_meters" => 12.0
|
||||
})
|
||||
|
||||
assert "is invalid" in errors_on(changeset).position
|
||||
end
|
||||
|
||||
test "concurrent tracking stops serialize and broadcast once", context do
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,17 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
assert {:error, :not_found} = Trust.unblock(context.requester_scope, "not-a-uuid")
|
||||
end
|
||||
|
||||
test "invalid report target identifiers are rejected without a query cast failure", context do
|
||||
for target <- ~w(request_id assignment_id message_id activity_id activity_message_id) do
|
||||
assert {:error, :not_found} =
|
||||
Trust.report(context.requester_scope, %{
|
||||
target => "not-a-uuid",
|
||||
"reason" => "unsafe",
|
||||
"details" => "Malformed target identifiers must not reach Ecto queries."
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
test "forged moderation identifiers return not found without crashing", context do
|
||||
moderator =
|
||||
user_fixture(display_name: "Moderator")
|
||||
|
|
@ -157,6 +168,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
assert entry.unique_people == 1
|
||||
assert entry.verified_people == 1
|
||||
assert entry.location_supported_people == 1
|
||||
assert is_nil(entry.user.email)
|
||||
assert is_nil(entry.user.hashed_password)
|
||||
|
||||
assert Repo.exists?(
|
||||
from signal in AbuseSignal,
|
||||
|
|
@ -240,6 +253,14 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
|
||||
assert {:error, changeset} = Help.create_request(context.requester_scope, invalid)
|
||||
assert "pickup_status has an invalid value" in errors_on(changeset).structured_data
|
||||
|
||||
nested =
|
||||
Map.put(context.attrs, "structured_data", %{
|
||||
"pickup_status" => %{"unexpected" => "reserved"}
|
||||
})
|
||||
|
||||
assert {:error, changeset} = Help.create_request(context.requester_scope, nested)
|
||||
assert "pickup_status has an invalid value" in errors_on(changeset).structured_data
|
||||
end
|
||||
|
||||
test "help creation rejects missing safety consent and non-help categories", context do
|
||||
|
|
@ -284,6 +305,15 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
)
|
||||
end
|
||||
|
||||
test "malformed runtime rate-limit configuration never crashes a request", context do
|
||||
old = Application.get_env(:who_need_help, :rate_limit_policies)
|
||||
Application.put_env(:who_need_help, :rate_limit_policies, ["not", "a", "map"])
|
||||
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end)
|
||||
|
||||
assert {:ok, :not_configured} =
|
||||
RateLimiter.check(:test_action, context.requester.id)
|
||||
end
|
||||
|
||||
test "restricted accounts cannot perform trust-sensitive actions", context do
|
||||
context.helper
|
||||
|> WhoNeedHelp.Accounts.User.moderation_changeset(%{moderation_status: :restricted})
|
||||
|
|
@ -293,14 +323,16 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
Help.create_request(context.helper_scope, context.attrs)
|
||||
end
|
||||
|
||||
test "hidden request coordinates are absent publicly but exact for a participant", context do
|
||||
test "hidden request coordinates remain hidden from an accepted helper", context do
|
||||
hidden_attrs = Map.put(context.attrs, "location_visibility", "hidden")
|
||||
{:ok, request} = Help.create_request(context.requester_scope, hidden_attrs)
|
||||
{:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
request = Help.get_request!(request.id)
|
||||
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
|
||||
|
||||
assert is_nil(WhoNeedHelp.Help.HelpRequest.public_coordinates(request))
|
||||
assert is_nil(Help.request_coordinates(outsider_scope, request))
|
||||
assert is_nil(Help.request_coordinates(context.helper_scope, request))
|
||||
|
||||
assert %{latitude: 50.4501, longitude: 30.5234, exact: true} =
|
||||
Help.request_coordinates(context.requester_scope, request)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ defmodule WhoNeedHelpWeb.MetricsControllerTest do
|
|||
]
|
||||
|
||||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
assert get_resp_header(conn, "x-wnh-node") == [to_string(node())]
|
||||
assert body =~ "# TYPE who_need_help_http_requests_total counter"
|
||||
assert body =~ "who_need_help_http_requests_total "
|
||||
assert body =~ "who_need_help_http_request_duration_microseconds_total "
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
|||
assert content_security_policy =~ "frame-ancestors 'none'"
|
||||
assert content_security_policy =~ "https://tile.openstreetmap.org"
|
||||
|
||||
assert get_resp_header(conn, "permissions-policy") == [
|
||||
"geolocation=(self), camera=(), microphone=(), payment=(), usb=()"
|
||||
]
|
||||
|
||||
assert html =~
|
||||
~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png")
|
||||
|
||||
|
|
@ -34,6 +38,22 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
|||
) == 1
|
||||
end
|
||||
|
||||
test "Phoenix logs filter authentication and handover secrets" do
|
||||
assert Phoenix.Logger.filter_values(%{
|
||||
"password" => "not-logged",
|
||||
"_csrf_token" => "not-logged",
|
||||
"token" => "not-logged",
|
||||
"handover" => %{"code" => "123456"},
|
||||
"email" => "visible@example.test"
|
||||
}) == %{
|
||||
"password" => "[FILTERED]",
|
||||
"_csrf_token" => "[FILTERED]",
|
||||
"token" => "[FILTERED]",
|
||||
"handover" => %{"code" => "[FILTERED]"},
|
||||
"email" => "visible@example.test"
|
||||
}
|
||||
end
|
||||
|
||||
test "GET / selects Russian locale", %{conn: conn} do
|
||||
conn = get(conn, ~p"/?locale=ru")
|
||||
assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется."
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
|
|||
use WhoNeedHelpWeb.ConnCase, async: true
|
||||
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
import Swoosh.TestAssertions
|
||||
|
||||
describe "GET /users/register" do
|
||||
test "renders registration page", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/register")
|
||||
response = html_response(conn, 200)
|
||||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
assert response =~ "Register"
|
||||
assert response =~ ~p"/users/log-in"
|
||||
assert response =~ ~p"/users/register"
|
||||
|
|
@ -20,6 +22,28 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
|
|||
end
|
||||
|
||||
describe "POST /users/register" do
|
||||
test "returns bad request for malformed parameters", %{conn: conn} do
|
||||
conn = post(conn, ~p"/users/register", %{"user" => "invalid"})
|
||||
|
||||
assert html_response(conn, 400) =~ "The registration form is invalid."
|
||||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
end
|
||||
|
||||
test "renders validation errors when email has a forged nested type", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, ~p"/users/register", %{
|
||||
"user" => %{
|
||||
"email" => %{"nested" => "value"},
|
||||
"display_name" => "Malformed email",
|
||||
"locale" => "en",
|
||||
"terms_accepted" => "true"
|
||||
}
|
||||
})
|
||||
|
||||
assert html_response(conn, 200) =~ "blank"
|
||||
refute get_session(conn, :user_token)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "creates account but does not log in", %{conn: conn} do
|
||||
email = unique_user_email()
|
||||
|
|
@ -33,7 +57,25 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
|
|||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
|
||||
assert conn.assigns.flash["info"] =~
|
||||
~r/An email was sent to .*, please access it to confirm your account/
|
||||
"If this address can be registered or signed in, login instructions will arrive shortly."
|
||||
|
||||
assert_email_sent()
|
||||
end
|
||||
|
||||
test "does not reveal whether a valid email is already registered", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
params = %{"user" => valid_user_attributes(email: user.email)}
|
||||
|
||||
existing_conn = post(conn, ~p"/users/register", params)
|
||||
|
||||
assert redirected_to(existing_conn) == ~p"/users/log-in"
|
||||
|
||||
assert existing_conn.assigns.flash["info"] ==
|
||||
"If this address can be registered or signed in, login instructions will arrive shortly."
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
Enum.any?(email.to, fn {_name, address} -> address == user.email end)
|
||||
end)
|
||||
end
|
||||
|
||||
test "render errors for invalid data", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
use WhoNeedHelpWeb.ConnCase, async: false
|
||||
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
import Swoosh.TestAssertions
|
||||
alias WhoNeedHelp.Accounts
|
||||
|
||||
setup do
|
||||
|
|
@ -12,9 +13,12 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
test "renders login page", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/log-in")
|
||||
response = html_response(conn, 200)
|
||||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
assert response =~ "Log in"
|
||||
assert response =~ ~p"/users/register"
|
||||
assert response =~ "Log in with email"
|
||||
assert response =~ ~s(id="magic-link-fragment-form")
|
||||
assert response =~ ~s(hidden)
|
||||
end
|
||||
|
||||
test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do
|
||||
|
|
@ -41,38 +45,6 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "GET /users/log-in/:token" do
|
||||
test "renders confirmation page for unconfirmed user", %{conn: conn, unconfirmed_user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_login_instructions(user, url)
|
||||
end)
|
||||
|
||||
conn = get(conn, ~p"/users/log-in/#{token}")
|
||||
assert html_response(conn, 200) =~ "Confirm and stay logged in"
|
||||
end
|
||||
|
||||
test "renders login page for confirmed user", %{conn: conn, user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_login_instructions(user, url)
|
||||
end)
|
||||
|
||||
conn = get(conn, ~p"/users/log-in/#{token}")
|
||||
html = html_response(conn, 200)
|
||||
refute html =~ "Confirm my account"
|
||||
assert html =~ "Log in"
|
||||
end
|
||||
|
||||
test "raises error for invalid token", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/log-in/invalid-token")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"Magic link is invalid or it has expired."
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/log-in - email and password" do
|
||||
test "logs the user in", %{conn: conn, user: user} do
|
||||
user = set_password(user)
|
||||
|
|
@ -189,7 +161,16 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
end
|
||||
|
||||
describe "POST /users/log-in - magic link" do
|
||||
test "returns bad request for malformed parameters", %{conn: conn} do
|
||||
conn = post(conn, ~p"/users/log-in", %{"user" => %{"email" => ["invalid"]}})
|
||||
|
||||
assert html_response(conn, 400) =~ "The sign-in form is invalid."
|
||||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
end
|
||||
|
||||
test "sends magic link email when user exists", %{conn: conn, user: user} do
|
||||
assert_email_sent()
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in", %{
|
||||
"user" => %{"email" => user.email}
|
||||
|
|
@ -197,6 +178,11 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
|
||||
assert WhoNeedHelp.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login"
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
email.text_body =~ "/users/log-in#token=" and
|
||||
not String.contains?(email.text_body, "/users/log-in/")
|
||||
end)
|
||||
end
|
||||
|
||||
test "does not send a magic link for a suspended user", %{conn: conn, user: user} do
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
|
||||
alias WhoNeedHelp.Accounts
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
import Swoosh.TestAssertions
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
|
|
@ -70,18 +71,29 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
assert response =~ "Settings"
|
||||
assert response =~ "should be at least 12 character(s)"
|
||||
assert response =~ "does not match password"
|
||||
refute response =~ ~s(value="too short")
|
||||
refute response =~ ~s(value="does not match")
|
||||
|
||||
assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token)
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /users/settings (change email form)" do
|
||||
test "returns bad request for malformed parameters", %{conn: conn} do
|
||||
conn = put(conn, ~p"/users/settings", %{"action" => "unknown"})
|
||||
|
||||
assert html_response(conn, 400) =~ "The settings form is invalid."
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "updates the user email", %{conn: conn, user: user} do
|
||||
assert_email_sent()
|
||||
changed_email = unique_user_email()
|
||||
|
||||
conn =
|
||||
put(conn, ~p"/users/settings", %{
|
||||
"action" => "update_email",
|
||||
"user" => %{"email" => unique_user_email()}
|
||||
"user" => %{"email" => changed_email}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
|
@ -89,6 +101,11 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
|
||||
"A link to confirm your email"
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
email.text_body =~ "/users/settings/confirm-email#token=" and
|
||||
not String.contains?(email.text_body, "/users/settings/confirm-email/")
|
||||
end)
|
||||
|
||||
assert Accounts.get_user_by_email(user.email)
|
||||
end
|
||||
|
||||
|
|
@ -103,9 +120,29 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
assert response =~ "Settings"
|
||||
assert response =~ "must have the @ sign and no spaces"
|
||||
end
|
||||
|
||||
test "rate limits repeated email-change instructions", %{conn: conn} do
|
||||
previous = Application.get_env(:who_need_help, :rate_limit_policies)
|
||||
|
||||
Application.put_env(:who_need_help, :rate_limit_policies, %{
|
||||
"email_change_email" => %{limit: 1, window_seconds: 60}
|
||||
})
|
||||
|
||||
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end)
|
||||
|
||||
email = unique_user_email()
|
||||
params = %{"action" => "update_email", "user" => %{"email" => email}}
|
||||
|
||||
assert redirected_to(put(conn, ~p"/users/settings", params)) == ~p"/users/settings"
|
||||
|
||||
limited_conn = put(conn, ~p"/users/settings", params)
|
||||
response = html_response(limited_conn, 429)
|
||||
|
||||
assert response =~ "Too many email-change requests"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /users/settings/confirm-email/:token" do
|
||||
describe "POST /users/settings/confirm-email" do
|
||||
setup %{user: user} do
|
||||
email = unique_user_email()
|
||||
|
||||
|
|
@ -118,7 +155,12 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
end
|
||||
|
||||
test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
|
||||
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
|
||||
page_conn = get(conn, ~p"/users/settings/confirm-email")
|
||||
page_response = html_response(page_conn, 200)
|
||||
assert page_response =~ ~s(id="email-change-fragment-form")
|
||||
assert page_response =~ ~s(hidden)
|
||||
|
||||
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => token})
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
|
||||
|
|
@ -127,7 +169,7 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
refute Accounts.get_user_by_email(user.email)
|
||||
assert Accounts.get_user_by_email(email)
|
||||
|
||||
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
|
||||
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => token})
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
|
|
@ -136,7 +178,31 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
end
|
||||
|
||||
test "does not update email with invalid token", %{conn: conn, user: user} do
|
||||
conn = get(conn, ~p"/users/settings/confirm-email/oops")
|
||||
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => "oops"})
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
"Email change link is invalid or it has expired"
|
||||
|
||||
assert Accounts.get_user_by_email(user.email)
|
||||
end
|
||||
|
||||
test "does not update email without a token", %{conn: conn, user: user} do
|
||||
conn = post(conn, ~p"/users/settings/confirm-email", %{})
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
"Email change link is invalid or it has expired"
|
||||
|
||||
assert Accounts.get_user_by_email(user.email)
|
||||
end
|
||||
|
||||
test "does not raise when the token has a forged nested type", %{conn: conn, user: user} do
|
||||
conn =
|
||||
post(conn, ~p"/users/settings/confirm-email", %{
|
||||
"token" => %{"nested" => "value"}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
|
|
@ -147,8 +213,19 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
|
||||
test "redirects if user is not logged in", %{token: token} do
|
||||
conn = build_conn()
|
||||
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
|
||||
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => token})
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
|
||||
@tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute)
|
||||
test "accepts a valid token outside sudo mode", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
email: email
|
||||
} do
|
||||
conn = post(conn, ~p"/users/settings/confirm-email", %{"token" => token})
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
assert Accounts.get_user_by_email(email)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,6 +23,32 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
assert html =~ "Створити термінову заявку"
|
||||
end
|
||||
|
||||
test "profile social-link creation uses the shared action limiter", %{conn: conn} do
|
||||
previous = Application.get_env(:who_need_help, :rate_limit_policies)
|
||||
|
||||
Application.put_env(:who_need_help, :rate_limit_policies, %{
|
||||
"add_social" => %{limit: 1, window_seconds: 60}
|
||||
})
|
||||
|
||||
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/profile")
|
||||
|
||||
assert render_submit(view, "add-social", %{
|
||||
"social_identity" => %{
|
||||
"provider" => "telegram",
|
||||
"profile_url" => "https://t.me/rate_limit_first"
|
||||
}
|
||||
}) =~ "Social link added as unverified."
|
||||
|
||||
assert render_submit(view, "add-social", %{
|
||||
"social_identity" => %{
|
||||
"provider" => "github",
|
||||
"profile_url" => "https://github.com/rate-limit-second"
|
||||
}
|
||||
}) =~ "Too many actions in the configured time window."
|
||||
end
|
||||
|
||||
test "invalid request and activity identifiers redirect without crashing", %{conn: conn} do
|
||||
assert {:error, {:live_redirect, %{to: "/requests"}}} =
|
||||
live(conn, "/requests/not-a-uuid")
|
||||
|
|
@ -59,6 +85,38 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
refute render(view) =~ "Realtime medicine pickup"
|
||||
end
|
||||
|
||||
test "request realtime updates keep a bounded window without skipping pagination" do
|
||||
category = Catalog.seed_defaults()
|
||||
requester = user_fixture(display_name: "Bounded realtime requester")
|
||||
viewer = user_fixture(display_name: "Bounded realtime viewer")
|
||||
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/requests")
|
||||
|
||||
requests =
|
||||
for index <- 1..25 do
|
||||
attrs =
|
||||
category
|
||||
|> request_attrs()
|
||||
|> Map.put("title", "Bounded realtime request #{index}")
|
||||
|> Map.put(
|
||||
"expires_at",
|
||||
DateTime.utc_now(:second) |> DateTime.add(3 * 60 * 60 + index, :second)
|
||||
)
|
||||
|
||||
{:ok, request} = Help.create_request(Accounts.Scope.for_user(requester), attrs)
|
||||
request
|
||||
end
|
||||
|
||||
render(view)
|
||||
first = List.first(requests)
|
||||
overflow = List.last(requests)
|
||||
|
||||
assert has_element?(view, "#open-request-#{first.id}")
|
||||
refute has_element?(view, "#open-request-#{overflow.id}")
|
||||
|
||||
view |> element("button", "Load more") |> render_click()
|
||||
assert has_element?(view, "#open-request-#{overflow.id}")
|
||||
end
|
||||
|
||||
test "activity index applies PubSub updates without a full page reload" do
|
||||
Catalog.seed_defaults()
|
||||
organizer = user_fixture(display_name: "Realtime organizer")
|
||||
|
|
@ -89,6 +147,45 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
assert render(view) =~ "Realtime coffee meetup"
|
||||
end
|
||||
|
||||
test "activity realtime updates keep a bounded window without skipping pagination" do
|
||||
Catalog.seed_defaults()
|
||||
organizer = user_fixture(display_name: "Bounded realtime organizer")
|
||||
viewer = user_fixture(display_name: "Bounded realtime activity viewer")
|
||||
|
||||
category =
|
||||
Catalog.list_categories(:activity)
|
||||
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
||||
|
||||
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/activities")
|
||||
|
||||
activities =
|
||||
for index <- 1..25 do
|
||||
attrs =
|
||||
category
|
||||
|> activity_attrs()
|
||||
|> Map.put("title", "Bounded realtime activity #{index}")
|
||||
|> Map.put(
|
||||
"starts_at",
|
||||
DateTime.utc_now(:second) |> DateTime.add(3 * 60 * 60 + index, :second)
|
||||
)
|
||||
|
||||
{:ok, activity} =
|
||||
Activities.create_activity(Accounts.Scope.for_user(organizer), attrs)
|
||||
|
||||
activity
|
||||
end
|
||||
|
||||
render(view)
|
||||
first = List.first(activities)
|
||||
overflow = List.last(activities)
|
||||
|
||||
assert has_element?(view, "#open-activity-#{first.id}")
|
||||
refute has_element?(view, "#open-activity-#{overflow.id}")
|
||||
|
||||
view |> element("button", "Load more") |> render_click()
|
||||
assert has_element?(view, "#open-activity-#{overflow.id}")
|
||||
end
|
||||
|
||||
test "new request form is driven by category structured fields", %{conn: conn} do
|
||||
category = Catalog.seed_defaults()
|
||||
{:ok, view, html} = live(conn, ~p"/requests/new")
|
||||
|
|
@ -259,6 +356,34 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
assert html =~ "I will be there."
|
||||
assert render(organizer_view) =~ "I will be there."
|
||||
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
for index <- 1..51 do
|
||||
body =
|
||||
case index do
|
||||
1 -> "oldest-realtime-activity-message"
|
||||
51 -> "newest-realtime-activity-message"
|
||||
_ -> "bounded activity message #{index}"
|
||||
end
|
||||
|
||||
send(
|
||||
organizer_view.pid,
|
||||
{:activity_message,
|
||||
%WhoNeedHelp.Activities.Message{
|
||||
id: Ecto.UUID.generate(),
|
||||
activity_id: activity.id,
|
||||
sender_id: participant.id,
|
||||
sender: participant,
|
||||
body: body,
|
||||
inserted_at: DateTime.add(now, index, :second)
|
||||
}}
|
||||
)
|
||||
end
|
||||
|
||||
bounded_activity_html = render(organizer_view)
|
||||
refute bounded_activity_html =~ "oldest-realtime-activity-message"
|
||||
assert bounded_activity_html =~ "newest-realtime-activity-message"
|
||||
|
||||
stop_live_view(organizer_view)
|
||||
stop_live_view(participant_view)
|
||||
end
|
||||
|
|
@ -294,6 +419,35 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
assert_push_event(helper_view, "reset-message-form", %{id: "message-form"})
|
||||
assert render(requester_view) =~ "I am on my way to the pharmacy."
|
||||
|
||||
now = DateTime.utc_now(:second)
|
||||
assignment_id = Repo.get_by!(WhoNeedHelp.Help.Assignment, request_id: request.id).id
|
||||
|
||||
for index <- 1..51 do
|
||||
body =
|
||||
case index do
|
||||
1 -> "oldest-realtime-help-message"
|
||||
51 -> "newest-realtime-help-message"
|
||||
_ -> "bounded help message #{index}"
|
||||
end
|
||||
|
||||
send(
|
||||
requester_view.pid,
|
||||
{:new_message,
|
||||
%WhoNeedHelp.Messaging.Message{
|
||||
id: Ecto.UUID.generate(),
|
||||
assignment_id: assignment_id,
|
||||
sender_id: helper.id,
|
||||
sender: helper,
|
||||
body: body,
|
||||
inserted_at: DateTime.add(now, index, :second)
|
||||
}}
|
||||
)
|
||||
end
|
||||
|
||||
bounded_help_html = render(requester_view)
|
||||
refute bounded_help_html =~ "oldest-realtime-help-message"
|
||||
assert bounded_help_html =~ "newest-realtime-help-message"
|
||||
|
||||
send(
|
||||
requester_view.pid,
|
||||
{:position_updated, helper.id,
|
||||
|
|
@ -431,6 +585,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
}
|
||||
end
|
||||
|
||||
defp activity_attrs(category) do
|
||||
%{
|
||||
"title" => "Public coffee meetup",
|
||||
"description" => "Meet at a public café for a short conversation.",
|
||||
"structured_data" => %{"setting" => "cafe"},
|
||||
"location_label" => "Central square",
|
||||
"latitude" => "50.4501",
|
||||
"longitude" => "30.5234",
|
||||
"location_visibility" => "approximate_public",
|
||||
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
|
||||
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
|
||||
"capacity" => 3,
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
}
|
||||
end
|
||||
|
||||
defp stop_live_view(%Phoenix.LiveViewTest.View{
|
||||
proxy: {_ref, _topic, proxy_pid}
|
||||
}) do
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user