From 474c9197c035dc27fb3810a4754fff8966949e6e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 19 Feb 2025 09:39:41 -0500 Subject: [PATCH 1/2] feat: Authenticate private resource requests Unauthenticated requests fail when attempting to load resources from a private site--e.g., an image. --- .../android/ui/posts/EditPostActivity.kt | 5 +- .../gutenberg/GutenbergKitEditorFragment.java | 126 +++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt index cabf4019adde..7efb181eecde 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt @@ -2510,6 +2510,7 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor "postType" to postType, "postTitle" to editPostRepository.getPost()?.title, "postContent" to editPostRepository.getPost()?.content, + "siteURL" to site.url, "siteApiRoot" to siteApiRoot, "authHeader" to authHeader, "siteApiNamespace" to siteApiNamespace, @@ -2521,7 +2522,9 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor isNewPost, gutenbergWebViewAuthorizationData, jetpackFeatureRemovalPhaseHelper.shouldShowJetpackPoweredEditorFeatures(), - settings + settings, + site.isPrivate || site.isComingSoon, + site.isPrivateWPComAtomic ) } diff --git a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java index d87658f0ff7f..2cefd542b051 100644 --- a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java +++ b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java @@ -16,6 +16,8 @@ import android.view.ViewGroup; import android.webkit.URLUtil; import android.webkit.ValueCallback; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -44,6 +46,7 @@ import org.wordpress.android.util.helpers.MediaFile; import org.wordpress.android.util.helpers.MediaGallery; import org.wordpress.aztec.IHistoryListener; +import org.wordpress.gutenberg.GutenbergRequestInterceptor; import org.wordpress.gutenberg.GutenbergView; import org.wordpress.gutenberg.GutenbergView.HistoryChangeListener; import org.wordpress.gutenberg.GutenbergView.LogJsExceptionListener; @@ -51,11 +54,24 @@ import org.wordpress.gutenberg.GutenbergView.TitleAndContentCallback; import org.wordpress.gutenberg.GutenbergWebViewPool; +import java.io.IOException; import java.io.Serializable; +import java.net.MalformedURLException; +import java.net.URL; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.CountDownLatch; +import okhttp3.Headers; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + import static org.wordpress.gutenberg.Media.createMediaUsingMimeType; public class GutenbergKitEditorFragment extends EditorFragmentAbstract implements @@ -64,7 +80,8 @@ public class GutenbergKitEditorFragment extends EditorFragmentAbstract implement EditorThemeUpdateListener, GutenbergDialogPositiveClickInterface, GutenbergDialogNegativeClickInterface, - GutenbergNetworkConnectionListener { + GutenbergNetworkConnectionListener, + GutenbergRequestInterceptor { @Nullable private GutenbergView mGutenbergView; private static final String GUTENBERG_EDITOR_NAME = "gutenberg"; private static final String KEY_HTML_MODE_ENABLED = "KEY_HTML_MODE_ENABLED"; @@ -88,12 +105,17 @@ public class GutenbergKitEditorFragment extends EditorFragmentAbstract implement private boolean mEditorDidMount; @Nullable private static Map mSettings; + private static boolean mIsPrivate = false; + private static boolean mIsPrivateAtomic = false; + @NonNull OkHttpClient mHttpClient = new OkHttpClient(); public static GutenbergKitEditorFragment newInstance(Context context, boolean isNewPost, GutenbergWebViewAuthorizationData webViewAuthorizationData, boolean jetpackFeaturesEnabled, - @Nullable Map settings) { + @Nullable Map settings, + boolean isPrivate, + boolean isPrivateAtomic) { GutenbergKitEditorFragment fragment = new GutenbergKitEditorFragment(); Bundle args = new Bundle(); args.putBoolean(ARG_IS_NEW_POST, isNewPost); @@ -102,6 +124,8 @@ public static GutenbergKitEditorFragment newInstance(Context context, fragment.setArguments(args); SavedInstanceDatabase db = SavedInstanceDatabase.Companion.getDatabase(context); mSettings = settings; + mIsPrivate = isPrivate; + mIsPrivateAtomic = isPrivateAtomic; if (db != null) { db.addParcel(ARG_GUTENBERG_WEB_VIEW_AUTH_DATA, webViewAuthorizationData); } @@ -150,6 +174,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa mGutenbergView.setEditorDidBecomeAvailable(view -> { mEditorFragmentListener.onEditorFragmentContentReady(new ArrayList<>(), false); }); + mGutenbergView.setRequestInterceptor(this); Integer postId = (Integer) mSettings.get("postId"); if (postId != null && postId == 0) { @@ -566,4 +591,101 @@ public void onGutenbergDialogNegativeClicked(@NonNull String instanceTag) { public void onConnectionStatusChange(boolean isConnected) { // Unused, no-op retained for the shared interface with Gutenberg } + + @Override + public boolean canIntercept(@NonNull WebResourceRequest request) { + Uri url = request.getUrl(); + + return mIsPrivate && isSiteHostedMediaFile(url.toString()); + } + + boolean isSiteHostedMediaFile(@NonNull String urlString) { + String siteURL = (String) (mSettings != null ? mSettings.get("siteURL") : ""); + Set mediaExtensions = new HashSet<>(Arrays.asList( + "jpg", "jpeg", "png", "gif", "bmp", "webp", + "mp4", "mov", "avi", "mkv", + "mp3", "wav", "flac" + )); + + try { + URL url = new URL(urlString); + URL siteUrlObj = new URL(siteURL); + + // Check if the URL is from the same host as the site URL + if (!url.getHost().equalsIgnoreCase(siteUrlObj.getHost())) { + return false; + } + + // Extract the file name and extension + String path = url.getPath(); + int lastDotIndex = path.lastIndexOf('.'); + if (lastDotIndex == -1) { + return false; + } + + String extension = path.substring(lastDotIndex + 1).toLowerCase(Locale.ROOT); + + // Check if the extension is in the list of media extensions + return mediaExtensions.contains(extension); + } catch (MalformedURLException e) { + // Handle invalid URLs + return false; + } + } + + @Nullable @Override + public WebResourceResponse handleRequest(@NonNull WebResourceRequest request) { + Uri url = request.getUrl(); + + String proxyUrl = url.toString(); + if (mIsPrivateAtomic) { + proxyUrl = getPrivateResourceProxyUrl(url); + } + + try { + Request okHttpRequest = new Request.Builder() + .url(proxyUrl) + .headers(Headers.of(request.getRequestHeaders())) + .addHeader("Authorization", mSettings.get("authHeader").toString()) + .build(); + + Response response = mHttpClient.newCall(okHttpRequest).execute(); + + ResponseBody body = response.body(); + if (body == null) { + return null; + } + + okhttp3.MediaType contentType = body.contentType(); + if (contentType == null) { + return null; + } + + return new WebResourceResponse( + contentType.toString(), + response.header("content-encoding"), + body.byteStream() + ); + } catch (IOException e) { + // We don't need to handle this ourselves, just tell the WebView that + // we weren't able to fetch the resource + return null; + } + } + + private static @NonNull String getPrivateResourceProxyUrl(@NonNull Uri url) { + Uri newUri = new Uri.Builder() + .scheme("https") + .authority("public-api.wordpress.com") + .appendPath("wpcom") + .appendPath("v2") + .appendPath("sites") + .appendPath(url.getAuthority()) + .appendPath("atomic-auth-proxy") + .appendPath("file") + .appendEncodedPath(url.getPath().substring(1)) // Remove leading '/' + .build(); + + return newUri.toString(); + } } From fc246b12ba8e87fcb5824cb5f59e59d536dd32d7 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 19 Feb 2025 10:24:43 -0500 Subject: [PATCH 2/2] style: Address lint warnings Avoid NullPointerExceptions. --- .../gutenberg/GutenbergKitEditorFragment.java | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java index 2cefd542b051..b76582ecf73b 100644 --- a/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java +++ b/libs/editor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergKitEditorFragment.java @@ -60,6 +60,7 @@ import java.net.URL; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.Locale; import java.util.Map; @@ -69,6 +70,7 @@ import okhttp3.Headers; import okhttp3.OkHttpClient; import okhttp3.Request; +import okhttp3.Request.Builder; import okhttp3.Response; import okhttp3.ResponseBody; @@ -104,7 +106,7 @@ public class GutenbergKitEditorFragment extends EditorFragmentAbstract implement private boolean mEditorDidMount; - @Nullable private static Map mSettings; + @NonNull private static Map mSettings = Collections.emptyMap(); private static boolean mIsPrivate = false; private static boolean mIsPrivateAtomic = false; @NonNull OkHttpClient mHttpClient = new OkHttpClient(); @@ -113,7 +115,7 @@ public static GutenbergKitEditorFragment newInstance(Context context, boolean isNewPost, GutenbergWebViewAuthorizationData webViewAuthorizationData, boolean jetpackFeaturesEnabled, - @Nullable Map settings, + @NonNull Map settings, boolean isPrivate, boolean isPrivateAtomic) { GutenbergKitEditorFragment fragment = new GutenbergKitEditorFragment(); @@ -600,7 +602,7 @@ public boolean canIntercept(@NonNull WebResourceRequest request) { } boolean isSiteHostedMediaFile(@NonNull String urlString) { - String siteURL = (String) (mSettings != null ? mSettings.get("siteURL") : ""); + String siteURL = (String) mSettings.get("siteURL"); Set mediaExtensions = new HashSet<>(Arrays.asList( "jpg", "jpeg", "png", "gif", "bmp", "webp", "mp4", "mov", "avi", "mkv", @@ -642,15 +644,19 @@ public WebResourceResponse handleRequest(@NonNull WebResourceRequest request) { proxyUrl = getPrivateResourceProxyUrl(url); } + String authHeader = (String) mSettings.get("authHeader"); + if (authHeader == null) { + return null; + } + try { - Request okHttpRequest = new Request.Builder() + Request okHttpRequest = new Builder() .url(proxyUrl) .headers(Headers.of(request.getRequestHeaders())) - .addHeader("Authorization", mSettings.get("authHeader").toString()) + .addHeader("Authorization", authHeader) .build(); Response response = mHttpClient.newCall(okHttpRequest).execute(); - ResponseBody body = response.body(); if (body == null) { return null; @@ -674,6 +680,11 @@ public WebResourceResponse handleRequest(@NonNull WebResourceRequest request) { } private static @NonNull String getPrivateResourceProxyUrl(@NonNull Uri url) { + String path = url.getPath(); + if (path != null && path.startsWith("/")) { + path = path.substring(1); // Remove leading '/' + } + Uri newUri = new Uri.Builder() .scheme("https") .authority("public-api.wordpress.com") @@ -683,7 +694,7 @@ public WebResourceResponse handleRequest(@NonNull WebResourceRequest request) { .appendPath(url.getAuthority()) .appendPath("atomic-auth-proxy") .appendPath("file") - .appendEncodedPath(url.getPath().substring(1)) // Remove leading '/' + .appendEncodedPath(path) .build(); return newUri.toString();