From 289930783fcce244bab6ba22b3a1c361d14b274e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 19 Feb 2025 09:39:41 -0500 Subject: [PATCH 1/4] 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 941f8ea5056d..d3adc836822c 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 @@ -2528,6 +2528,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, "namespaceExcludedPaths" to arrayOf("/wpcom/v2/following/recommendations", "/wpcom/v2/following/mine"), "authHeader" to authHeader, @@ -2553,7 +2554,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 3264c1a6cb13..90c493bcb06b 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; @@ -53,12 +56,25 @@ import org.wordpress.gutenberg.EditorConfiguration; import org.wordpress.gutenberg.WebViewGlobal; +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.List; +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 @@ -67,7 +83,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"; @@ -93,12 +110,17 @@ public class GutenbergKitEditorFragment extends EditorFragmentAbstract implement private View mRootView; @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, @Nullable 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); @@ -107,6 +129,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); } @@ -166,6 +190,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa mEditorFragmentListener.onEditorFragmentContentReady(new ArrayList<>(), false); setEditorProgressBarVisibility(false); }); + mGutenbergView.setRequestInterceptor(this); return mRootView; } @@ -608,4 +633,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 74749148b4b8f442eb34d688adba6186300d38ef Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 19 Feb 2025 10:24:43 -0500 Subject: [PATCH 2/4] 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 90c493bcb06b..fda7b59a9369 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 @@ -62,6 +62,7 @@ import java.net.URL; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -72,6 +73,7 @@ import okhttp3.Headers; import okhttp3.OkHttpClient; import okhttp3.Request; +import okhttp3.Request.Builder; import okhttp3.Response; import okhttp3.ResponseBody; @@ -109,7 +111,7 @@ public class GutenbergKitEditorFragment extends EditorFragmentAbstract implement @Nullable private View mRootView; - @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(); @@ -118,7 +120,7 @@ public static GutenbergKitEditorFragment newInstance(Context context, boolean isNewPost, @Nullable GutenbergWebViewAuthorizationData webViewAuthorizationData, boolean jetpackFeaturesEnabled, - @Nullable Map settings, + @NonNull Map settings, boolean isPrivate, boolean isPrivateAtomic) { GutenbergKitEditorFragment fragment = new GutenbergKitEditorFragment(); @@ -642,7 +644,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", @@ -684,15 +686,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; @@ -716,6 +722,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") @@ -725,7 +736,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(); From 6c2c50812e88a5586ead537acdc54dc70025d84c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 May 2025 07:21:57 -0400 Subject: [PATCH 3/4] style: Address "function too long" lint warning Lint configuration warned the `createGutenbergKitEditorFragment` function was over 60 lines. --- .../android/ui/posts/EditPostActivity.kt | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 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 d3adc836822c..8e191d9dd3ca 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 @@ -2498,8 +2498,23 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor onXpostsSettingsCapability(isXpostsCapable) } + val authorizationData = createGutenbergWebViewAuthorizationData() + val settings = createGutenbergSettings() + + return GutenbergKitEditorFragment.newInstance( + getContext(), + isNewPost, + authorizationData, + jetpackFeatureRemovalPhaseHelper.shouldShowJetpackPoweredEditorFeatures(), + settings, + site.isPrivate || site.isComingSoon, + site.isPrivateWPComAtomic + ) + } + + private fun createGutenbergWebViewAuthorizationData(): GutenbergWebViewAuthorizationData { val isWpCom = site.isWPCom || siteModel.isPrivateWPComAtomic || siteModel.isWPComAtomic - val gutenbergWebViewAuthorizationData = GutenbergWebViewAuthorizationData( + return GutenbergWebViewAuthorizationData( siteModel.url, isWpCom, accountStore.account.userId, @@ -2513,7 +2528,10 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor userAgent.toString(), isJetpackSsoEnabled ) + } + private fun createGutenbergSettings(): Map { + val isWpCom = site.isWPCom || siteModel.isPrivateWPComAtomic || siteModel.isWPComAtomic val postType = if (editPostRepository.isPage) "page" else "post" val siteApiRoot = if (isWpCom) "https://public-api.wordpress.com/" else "" val authToken = accountStore.accessToken @@ -2523,7 +2541,7 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor val languageString = perAppLocaleManager.getCurrentLocaleLanguageCode() val wpcomLocaleSlug = languageString.replace("_", "-").lowercase() - val settings = mutableMapOf( + return mutableMapOf( "postId" to editPostRepository.getPost()?.remotePostId?.toInt(), "postType" to postType, "postTitle" to editPostRepository.getPost()?.title, @@ -2548,16 +2566,6 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor ) ) ) - - return GutenbergKitEditorFragment.newInstance( - getContext(), - isNewPost, - gutenbergWebViewAuthorizationData, - jetpackFeatureRemovalPhaseHelper.shouldShowJetpackPoweredEditorFeatures(), - settings, - site.isPrivate || site.isComingSoon, - site.isPrivateWPComAtomic - ) } private fun createGutenbergEditorFragment(): GutenbergEditorFragment { From c8374f9600aa24d72150e79a82c284ce9c3b276b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 May 2025 07:55:09 -0400 Subject: [PATCH 4/4] feat: Expand supported media types We should authenticate requests for these common media types. --- .../editor/gutenberg/GutenbergKitEditorFragment.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 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 fda7b59a9369..755e94de0d6d 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 @@ -646,10 +646,12 @@ public boolean canIntercept(@NonNull WebResourceRequest request) { boolean isSiteHostedMediaFile(@NonNull String urlString) { String siteURL = (String) mSettings.get("siteURL"); Set mediaExtensions = new HashSet<>(Arrays.asList( - "jpg", "jpeg", "png", "gif", "bmp", "webp", - "mp4", "mov", "avi", "mkv", - "mp3", "wav", "flac" - )); + // Image formats + "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico", "tiff", "tif", "heic", "heif", + // Video formats + "mp4", "mov", "avi", "mkv", "webm", "m4v", "mpeg", "mpg", "3gp", "flv", "wmv", "mts", "m2ts", + // Audio formats + "mp3", "wav", "flac", "aac", "m4a", "ogg", "wma", "aiff", "mid", "midi")); try { URL url = new URL(urlString);