diff --git a/.github/changelog/fix-pre-publish-error-handling b/.github/changelog/fix-pre-publish-error-handling new file mode 100644 index 0000000..62bf5e2 --- /dev/null +++ b/.github/changelog/fix-pre-publish-error-handling @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +The Bluesky pre-publish preview now shows a clear message when it can't be generated — including a distinct one for permission errors — instead of failing without explanation. diff --git a/build/pre-publish-panel/plugin.asset.php b/build/pre-publish-panel/plugin.asset.php index 3f3e441..c904376 100644 --- a/build/pre-publish-panel/plugin.asset.php +++ b/build/pre-publish-panel/plugin.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins'), 'version' => '8fa0d7dd1bccedfa1d37'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins'), 'version' => '424b2fbff0951a70d6d3'); diff --git a/build/pre-publish-panel/plugin.js b/build/pre-publish-panel/plugin.js index 87967cb..0e21e6a 100644 --- a/build/pre-publish-panel/plugin.js +++ b/build/pre-publish-panel/plugin.js @@ -1,4 +1,4 @@ -(()=>{"use strict";var e={n:t=>{var r=t&&t.__esModule?()=>t.default:()=>t;return e.d(r,{a:r}),r},d:(t,r)=>{for(var s in r)e.o(r,s)&&!e.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:r[s]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.editor,r=window.wp.plugins,s=window.wp.data,i=window.wp.coreData,o=window.wp.element,n=window.wp.i18n,a=window.wp.apiFetch;var d=e.n(a);const p=window.wp.components,l="undefined"!=typeof window&&window.atmosphereEditor||{},u=l.metaKey||"atmosphere_disabled",c=l.previewPath||"/atmosphere/1.0/admin/pre-publish-preview";function h(e){switch(e){case"short-form":return(0,n.__)("Short note","atmosphere");case"truncate-link":return(0,n.__)("Text with link","atmosphere");case"teaser-thread":return(0,n.__)("Teaser thread","atmosphere");case"redacted":return(0,n.__)("Not shared","atmosphere");default:return(0,n.__)("Link card","atmosphere")}}function m(e){return Array.isArray(e)&&e.some(e=>e&&e.over_limit)}const w=window.ReactJSXRuntime;function _(){const{postId:e,postType:r,title:a,content:l,excerpt:_,status:f,password:b}=(0,s.useSelect)(e=>{const r=e(t.store);return{postId:r.getCurrentPostId(),postType:r.getCurrentPostType(),title:r.getEditedPostAttribute("title"),content:r.getEditedPostContent(),excerpt:r.getEditedPostAttribute("excerpt"),status:r.getEditedPostAttribute("status"),password:r.getEditedPostAttribute("password")}},[]),[y]=(0,i.useEntityProp)("postType",r,"meta"),P=!(!y||!y[u]),[x,g]=(0,o.useState)(null),[v,j]=(0,o.useState)(!0),[T,k]=(0,o.useState)(!1),E=(0,o.useRef)(null);if((0,o.useEffect)(()=>{if(e)return j(!0),k(!1),clearTimeout(E.current),E.current=setTimeout(()=>{d()({path:c,method:"POST",data:{id:e,title:a,content:l,excerpt:_,status:f,password:b,disabled:P}}).then(e=>{g(e),j(!1)}).catch(()=>{k(!0),j(!1)})},400),()=>clearTimeout(E.current)},[e,a,l,_,f,b,P]),v)return(0,w.jsx)(p.Spinner,{});if(T||!x)return(0,w.jsx)("p",{children:(0,n.__)("Could not load the Bluesky preview.","atmosphere")});if(!x.will_publish)return(0,w.jsx)(p.Notice,{status:"info",isDismissible:!1,children:x.reason||(0,n.__)("This post won’t be shared to Bluesky.","atmosphere")});const S=x.records||[];return(0,w.jsxs)("div",{className:"atmosphere-pre-publish",children:[(0,w.jsx)("p",{children:(0,n.sprintf)(/* translators: %s: the publishing strategy, e.g. "Short note" or "Link card". */ /* translators: %s: the publishing strategy, e.g. "Short note" or "Link card". */ -(0,n.__)("This post will be shared to Bluesky as: %s.","atmosphere"),h(x.strategy))}),1===S.length&&(0,w.jsx)("p",{className:S[0].over_limit?"atmosphere-over-limit":void 0,children:(0,n.sprintf)(/* translators: 1: character count, 2: the 300-character limit. */ /* translators: 1: character count, 2: the 300-character limit. */ -(0,n.__)("%1$d / %2$d characters","atmosphere"),S[0].characters,x.limit)}),S.length>1&&(0,w.jsx)("ul",{children:S.map((e,t)=>(0,w.jsx)("li",{className:e.over_limit?"atmosphere-over-limit":void 0,children:(0,n.sprintf)(/* translators: 1: post number in the thread, 2: character count, 3: the limit. */ /* translators: 1: post number in the thread, 2: character count, 3: the limit. */ -(0,n.__)("Post %1$d: %2$d / %3$d characters","atmosphere"),t+1,e.characters,x.limit)},t))}),m(S)&&(0,w.jsx)(p.Notice,{status:"warning",isDismissible:!1,children:(0,n.__)("Your text is over Bluesky’s limit and will be shortened when published.","atmosphere")})]})}(0,r.registerPlugin)("atmosphere-pre-publish-panel",{render:()=>(0,w.jsx)(t.PluginPrePublishPanel,{title:(0,n.__)("Bluesky","atmosphere"),initialOpen:!0,children:(0,w.jsx)(_,{})})})})(); \ No newline at end of file +(()=>{"use strict";var e={n:t=>{var r=t&&t.__esModule?()=>t.default:()=>t;return e.d(r,{a:r}),r},d:(t,r)=>{for(var s in r)e.o(r,s)&&!e.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:r[s]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.editor,r=window.wp.plugins,s=window.wp.data,i=window.wp.coreData,o=window.wp.element,n=window.wp.i18n,a=window.wp.apiFetch;var d=e.n(a);const p=window.wp.components,l="undefined"!=typeof window&&window.atmosphereEditor||{},u=l.metaKey||"atmosphere_disabled",c=l.previewPath||"/atmosphere/1.0/admin/pre-publish-preview";function h(e){switch(e){case"short-form":return(0,n.__)("Short note","atmosphere");case"truncate-link":return(0,n.__)("Text with link","atmosphere");case"teaser-thread":return(0,n.__)("Teaser thread","atmosphere");case"redacted":return(0,n.__)("Not shared","atmosphere");default:return(0,n.__)("Link card","atmosphere")}}function m(e){return Array.isArray(e)&&e.some(e=>e&&e.over_limit)}const w=window.ReactJSXRuntime;function _(){const{postId:e,postType:r,title:a,content:l,excerpt:_,status:f,password:b}=(0,s.useSelect)(e=>{const r=e(t.store);return{postId:r.getCurrentPostId(),postType:r.getCurrentPostType(),title:r.getEditedPostAttribute("title"),content:r.getEditedPostContent(),excerpt:r.getEditedPostAttribute("excerpt"),status:r.getEditedPostAttribute("status"),password:r.getEditedPostAttribute("password")}},[]),[v]=(0,i.useEntityProp)("postType",r,"meta"),y=!(!v||!v[u]),[P,x]=(0,o.useState)(null),[g,j]=(0,o.useState)(!0),[T,k]=(0,o.useState)(null),E=(0,o.useRef)(null);if((0,o.useEffect)(()=>{if(e)return j(!0),k(null),clearTimeout(E.current),E.current=setTimeout(()=>{d()({path:c,method:"POST",data:{id:e,title:a,content:l,excerpt:_,status:f,password:b,disabled:y}}).then(e=>{x(e),j(!1)}).catch(e=>{console.error("ATmosphere pre-publish preview failed:",e),k(e),j(!1)})},400),()=>clearTimeout(E.current)},[e,a,l,_,f,b,y]),g)return(0,w.jsx)(p.Spinner,{});if(T){const e=T?.code,t=T?.data?.status,r="rest_cookie_invalid_nonce"!==e&&("rest_forbidden"===e||401===t||403===t);return(0,w.jsx)("p",{children:r?(0,n.__)("You don’t have permission to preview this post.","atmosphere"):(0,n.__)("Could not load the Bluesky preview. Please try again.","atmosphere")})}if(!P)return(0,w.jsx)("p",{children:(0,n.__)("Could not load the Bluesky preview.","atmosphere")});if(!P.will_publish)return(0,w.jsx)(p.Notice,{status:"info",isDismissible:!1,children:P.reason||(0,n.__)("This post won’t be shared to Bluesky.","atmosphere")});const S=P.records||[];return(0,w.jsxs)("div",{className:"atmosphere-pre-publish",children:[(0,w.jsx)("p",{children:(0,n.sprintf)(/* translators: %s: the publishing strategy, e.g. "Short note" or "Link card". */ /* translators: %s: the publishing strategy, e.g. "Short note" or "Link card". */ +(0,n.__)("This post will be shared to Bluesky as: %s.","atmosphere"),h(P.strategy))}),1===S.length&&(0,w.jsx)("p",{className:S[0].over_limit?"atmosphere-over-limit":void 0,children:(0,n.sprintf)(/* translators: 1: character count, 2: the 300-character limit. */ /* translators: 1: character count, 2: the 300-character limit. */ +(0,n.__)("%1$d / %2$d characters","atmosphere"),S[0].characters,P.limit)}),S.length>1&&(0,w.jsx)("ul",{children:S.map((e,t)=>(0,w.jsx)("li",{className:e.over_limit?"atmosphere-over-limit":void 0,children:(0,n.sprintf)(/* translators: 1: post number in the thread, 2: character count, 3: the limit. */ /* translators: 1: post number in the thread, 2: character count, 3: the limit. */ +(0,n.__)("Post %1$d: %2$d / %3$d characters","atmosphere"),t+1,e.characters,P.limit)},t))}),m(S)&&(0,w.jsx)(p.Notice,{status:"warning",isDismissible:!1,children:(0,n.__)("Your text is over Bluesky’s limit and will be shortened when published.","atmosphere")})]})}(0,r.registerPlugin)("atmosphere-pre-publish-panel",{render:()=>(0,w.jsx)(t.PluginPrePublishPanel,{title:(0,n.__)("Bluesky","atmosphere"),initialOpen:!0,children:(0,w.jsx)(_,{})})})})(); \ No newline at end of file diff --git a/includes/rest/admin/class-pre-publish-controller.php b/includes/rest/admin/class-pre-publish-controller.php index 7a0fe4c..19409b5 100644 --- a/includes/rest/admin/class-pre-publish-controller.php +++ b/includes/rest/admin/class-pre-publish-controller.php @@ -20,6 +20,7 @@ use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; +use function Atmosphere\debug_log; use function Atmosphere\is_connected; use function Atmosphere\is_supported_post_type; @@ -228,6 +229,27 @@ public function get_preview( WP_REST_Request $request ) { try { $projection = ( new Post( $draft ) )->project(); + } catch ( \Throwable $e ) { + /* + * `project()` runs the_content over raw, unsaved editor markup, + * so a malformed block or a misbehaving content/shortcode filter + * can throw. Return a structured error (and log the post ID for + * support) instead of letting the keystroke-driven endpoint fatal + * into an opaque 500. + */ + debug_log( + \sprintf( + 'pre-publish projection failed for post %d: %s', + $draft->ID, + $e->getMessage() + ) + ); + + return new \WP_Error( + 'atmosphere_projection_failed', + \__( 'The Bluesky preview could not be generated.', 'atmosphere' ), + array( 'status' => 500 ) + ); } finally { \remove_filter( 'pre_http_request', $block_http, 0 ); } diff --git a/includes/rest/class-client-metadata-controller.php b/includes/rest/class-client-metadata-controller.php index c38a3fc..fc682e1 100644 --- a/includes/rest/class-client-metadata-controller.php +++ b/includes/rest/class-client-metadata-controller.php @@ -16,6 +16,7 @@ use Atmosphere\OAuth\Client; use WP_REST_Response; use WP_REST_Server; +use function Atmosphere\debug_log; use function Atmosphere\sanitize_text; /** @@ -141,6 +142,15 @@ public function get_metadata(): WP_REST_Response { \esc_html__( 'atmosphere_client_metadata must return an array with a non-empty string client_id and a redirect_uris list of admin URLs; falling back to the unfiltered metadata.', 'atmosphere' ), '1.0.0' ); + + /* + * `_doing_it_wrong()` is silent in production. Also route the + * failure through debug_log() so operators can opt into the + * signal via the `atmosphere_debug_log` filter without enabling + * WP_DEBUG site-wide — an OAuth client_id served from a + * misbehaving filter is worth surfacing. + */ + debug_log( 'atmosphere_client_metadata filter returned an invalid value; using the unfiltered metadata.' ); } $response = new WP_REST_Response( $metadata, 200 ); diff --git a/src/pre-publish-panel/plugin.js b/src/pre-publish-panel/plugin.js index 6f3f2c1..b9fb008 100644 --- a/src/pre-publish-panel/plugin.js +++ b/src/pre-publish-panel/plugin.js @@ -45,7 +45,7 @@ function PrePublishPanel() { const [ preview, setPreview ] = useState( null ); const [ loading, setLoading ] = useState( true ); - const [ error, setError ] = useState( false ); + const [ error, setError ] = useState( null ); const debounce = useRef( null ); useEffect( () => { @@ -54,7 +54,7 @@ function PrePublishPanel() { } setLoading( true ); - setError( false ); + setError( null ); // Debounce so each keystroke doesn't fire a projector request. clearTimeout( debounce.current ); @@ -76,8 +76,16 @@ function PrePublishPanel() { setPreview( result ); setLoading( false ); } ) - .catch( () => { - setError( true ); + .catch( ( err ) => { + // Keep the error so the message can distinguish a + // permission failure from a transient one, and log it so + // a support report has something to go on. + // eslint-disable-next-line no-console -- Aid debugging. + console.error( + 'ATmosphere pre-publish preview failed:', + err + ); + setError( err ); setLoading( false ); } ); }, 400 ); @@ -89,7 +97,33 @@ function PrePublishPanel() { return ; } - if ( error || ! preview ) { + if ( error ) { + const errorCode = error?.code; + const errorStatus = error?.data?.status; + // An expired/invalid nonce is a 403 too, but it's transient (a + // reload fixes it), so it must not read as a permission failure. + const isAuth = + 'rest_cookie_invalid_nonce' !== errorCode && + ( 'rest_forbidden' === errorCode || + 401 === errorStatus || + 403 === errorStatus ); + + return ( +

+ { isAuth + ? __( + 'You don’t have permission to preview this post.', + 'atmosphere' + ) + : __( + 'Could not load the Bluesky preview. Please try again.', + 'atmosphere' + ) } +

+ ); + } + + if ( ! preview ) { return (

{ __( 'Could not load the Bluesky preview.', 'atmosphere' ) }

); diff --git a/tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php b/tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php index e7d8ca5..0d0e6af 100644 --- a/tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php +++ b/tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php @@ -116,6 +116,36 @@ public function test_preview_connected_short_form() { $this->assertFalse( $data['records'][0]['over_limit'] ); } + /** + * A projection that throws (e.g. malformed block markup tripping a + * content filter) is caught and reported as a structured error, not an + * opaque fatal. + * + * @covers ::get_preview + */ + public function test_preview_returns_error_when_projection_throws() { + $post = self::factory()->post->create_and_get( array( 'post_title' => '' ) ); + + $thrower = static function () { + throw new \RuntimeException( 'boom' ); + }; + \add_filter( 'the_content', $thrower, 9 ); + + try { + $result = $this->controller->get_preview( + $this->make_request( $post->ID, array( 'content' => 'A quick note.' ) ) + ); + } finally { + // Remove in finally so a failed assertion below can't leak the + // throwing filter into later tests. + \remove_filter( 'the_content', $thrower, 9 ); + } + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'atmosphere_projection_failed', $result->get_error_code() ); + $this->assertSame( 500, $result->get_error_data()['status'] ); + } + /** * A draft (not yet published) reports the real character count, not a * redacted zero. The transformer redacts non-published posts, so the