@@ -15,6 +15,9 @@ const MAX_GITHUB_PULL_REQUEST_CANDIDATES = 3;
1515const MAX_GITHUB_CHANGED_FILES = 20 ;
1616const MAX_GITHUB_PULL_REQUEST_IMAGE_CANDIDATES = 20 ;
1717const MAX_GITHUB_PULL_REQUEST_IMAGE_ALT_CHARS = 200 ;
18+ const MAX_GITHUB_IMAGE_MEDIA_TYPE_CHECKS = 2 ;
19+ const MAX_GITHUB_IMAGE_REDIRECTS = 3 ;
20+ const GITHUB_IMAGE_MEDIA_TYPE_TIMEOUT_MS = 3_000 ;
1821const MARKDOWN_IMAGE_PATTERN =
1922 / ! \[ ( [ ^ \] ] * ) \] \( \s * (?: < ( [ ^ > \n ] + ) > | ( [ ^ \s ) \n ] + ) ) (?: \s + (?: " [ ^ " ] * " | ' [ ^ ' ] * ' | \( [ ^ ) ] * \) ) ) ? \s * \) / gu;
2023const HTML_IMAGE_PATTERN = / < i m g \b [ ^ > ] * > / giu;
@@ -24,6 +27,11 @@ const SCREENSHOT_LIKE_IMAGE_TEXT =
2427 / \b (?: a f t e r | b e f o r e | d e m o | d e s k t o p | m o b i l e | p r e v i e w | s c r e e n (?: [ - ] ? s h o t ) ? | u i ) \b / iu;
2528const REJECTED_IMAGE_TEXT = / \b (?: a v a t a r | b a d g e | c o v e r a g e | i c o n | l o g o | s h i e l d ) \b / iu;
2629const SUPPORTED_SLACK_IMAGE_EXTENSION = / \. (?: g i f | j p e ? g | p n g ) $ / iu;
30+ const SUPPORTED_SLACK_IMAGE_MEDIA_TYPES = new Set ( [
31+ 'image/gif' ,
32+ 'image/jpeg' ,
33+ 'image/png' ,
34+ ] ) ;
2735const EXTENSION_REQUIRED_GITHUB_IMAGE_HOSTS = new Set ( [
2836 'raw.githubusercontent.com' ,
2937 'user-images.githubusercontent.com' ,
@@ -62,6 +70,58 @@ function isAllowedPublicGitHubImageUrl(value: string): boolean {
6270 }
6371}
6472
73+ function isExtensionlessGitHubAttachmentUrl ( value : string ) : boolean {
74+ const url = new URL ( value ) ;
75+ return (
76+ url . hostname . toLowerCase ( ) === 'github.com' &&
77+ url . pathname . startsWith ( '/user-attachments/assets/' ) &&
78+ ! url . pathname . includes ( '.' )
79+ ) ;
80+ }
81+
82+ function isAllowedGitHubImageRedirect ( url : URL ) : boolean {
83+ const hostname = url . hostname . toLowerCase ( ) ;
84+ return (
85+ url . protocol === 'https:' &&
86+ ! url . username &&
87+ ! url . password &&
88+ ( hostname === 'github.com' || hostname . endsWith ( '.githubusercontent.com' ) )
89+ ) ;
90+ }
91+
92+ async function getPublicGitHubMediaType ( value : string ) : Promise < string | null > {
93+ let url = new URL ( value ) ;
94+ const signal = AbortSignal . timeout ( GITHUB_IMAGE_MEDIA_TYPE_TIMEOUT_MS ) ;
95+ for (
96+ let redirects = 0 ;
97+ redirects <= MAX_GITHUB_IMAGE_REDIRECTS ;
98+ redirects ++
99+ ) {
100+ const response = await fetch ( url , {
101+ method : 'HEAD' ,
102+ redirect : 'manual' ,
103+ signal,
104+ } ) ;
105+ if ( response . status >= 300 && response . status < 400 ) {
106+ const location = response . headers . get ( 'location' ) ;
107+ if ( ! location || redirects === MAX_GITHUB_IMAGE_REDIRECTS ) return null ;
108+ url = new URL ( location , url ) ;
109+ if ( ! isAllowedGitHubImageRedirect ( url ) ) return null ;
110+ continue ;
111+ }
112+ if ( ! response . ok ) return null ;
113+
114+ return (
115+ response . headers
116+ . get ( 'content-type' )
117+ ?. split ( ';' , 1 ) [ 0 ]
118+ ?. trim ( )
119+ . toLowerCase ( ) ?? null
120+ ) ;
121+ }
122+ return null ;
123+ }
124+
65125function normalizePullRequestImageCandidate (
66126 candidate : PullRequestImageCandidate ,
67127) : PullRequestImageCandidate | null {
@@ -79,9 +139,12 @@ function normalizePullRequestImageCandidate(
79139 } ;
80140}
81141
82- export function selectRepresentativeGitHubPullRequestImage (
142+ export async function selectRepresentativeGitHubPullRequestImage (
83143 body : string | null | undefined ,
84- ) : MergeAnnouncerPullRequestContext [ 'representativeImage' ] | null {
144+ resolveMediaType : (
145+ url : string ,
146+ ) => Promise < string | null > = getPublicGitHubMediaType ,
147+ ) : Promise < MergeAnnouncerPullRequestContext [ 'representativeImage' ] | null > {
85148 if ( ! body ?. trim ( ) ) return null ;
86149
87150 const candidates : PullRequestImageCandidate [ ] = [ ] ;
@@ -101,7 +164,7 @@ export function selectRepresentativeGitHubPullRequestImage(
101164 } ) ;
102165 }
103166
104- const selected = candidates
167+ const rankedCandidates = candidates
105168 . sort ( ( a , b ) => a . position - b . position )
106169 . slice ( 0 , MAX_GITHUB_PULL_REQUEST_IMAGE_CANDIDATES )
107170 . map ( normalizePullRequestImageCandidate )
@@ -111,9 +174,38 @@ export function selectRepresentativeGitHubPullRequestImage(
111174 Number ( SCREENSHOT_LIKE_IMAGE_TEXT . test ( b . altText ) ) -
112175 Number ( SCREENSHOT_LIKE_IMAGE_TEXT . test ( a . altText ) ) ;
113176 return screenshotScoreDifference || a . position - b . position ;
114- } ) [ 0 ] ;
177+ } ) ;
115178
116- return selected ? { url : selected . url , altText : selected . altText } : null ;
179+ const mediaTypeResults = await Promise . allSettled (
180+ rankedCandidates
181+ . filter ( ( candidate ) => isExtensionlessGitHubAttachmentUrl ( candidate . url ) )
182+ . slice ( 0 , MAX_GITHUB_IMAGE_MEDIA_TYPE_CHECKS )
183+ . map ( async ( candidate ) => ( {
184+ url : candidate . url ,
185+ mediaType : await resolveMediaType ( candidate . url ) ,
186+ } ) ) ,
187+ ) ;
188+ const mediaTypes = new Map (
189+ mediaTypeResults . flatMap ( ( result ) =>
190+ result . status === 'fulfilled'
191+ ? [ [ result . value . url , result . value . mediaType ] as const ]
192+ : [ ] ,
193+ ) ,
194+ ) ;
195+
196+ for ( const candidate of rankedCandidates ) {
197+ if ( isExtensionlessGitHubAttachmentUrl ( candidate . url ) ) {
198+ const mediaType = mediaTypes . get ( candidate . url ) ;
199+ if (
200+ ! mediaType ||
201+ ! SUPPORTED_SLACK_IMAGE_MEDIA_TYPES . has ( mediaType . toLowerCase ( ) )
202+ ) {
203+ continue ;
204+ }
205+ }
206+ return { url : candidate . url , altText : candidate . altText } ;
207+ }
208+ return null ;
117209}
118210
119211function getPullRequestNumberFromCommitMessage (
@@ -161,11 +253,13 @@ type GitHubPushWebhook = {
161253
162254type GitHubMergeAnnouncerDependencies = {
163255 getInstallationOctokit : typeof getInstallationOctokit ;
256+ getPublicMediaType : typeof getPublicGitHubMediaType ;
164257 selectRepresentativeImage : typeof selectRepresentativeGitHubPullRequestImage ;
165258} ;
166259
167260const githubMergeAnnouncerDependencies : GitHubMergeAnnouncerDependencies = {
168261 getInstallationOctokit,
262+ getPublicMediaType : getPublicGitHubMediaType ,
169263 selectRepresentativeImage : selectRepresentativeGitHubPullRequestImage ,
170264} ;
171265
@@ -276,7 +370,10 @@ export async function enrichGitHubMergeAnnouncerEvent(
276370 if ( payload . repository ?. private === false ) {
277371 try {
278372 representativeImage =
279- dependencies . selectRepresentativeImage ( pullRequest . body ) ?? undefined ;
373+ ( await dependencies . selectRepresentativeImage (
374+ pullRequest . body ,
375+ dependencies . getPublicMediaType ,
376+ ) ) ?? undefined ;
280377 } catch ( error ) {
281378 console . warn (
282379 `[mergeAnnouncer] Failed to select a pull request image for ${ payload . repository . full_name } #${ pullRequest . number } : ${ error instanceof Error ? error . message : String ( error ) } ` ,
0 commit comments