unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at testPDSNotExplode 294 lines 11 kB view raw
1import sanitizeHtml from 'sanitize-html' 2import { completeEnvironment } from './backendOptions.js' 3import { JSDOM } from 'jsdom' 4import { Emoji, Post } from '../models/index.js' 5 6const parser = new new JSDOM('<html></html>').window.DOMParser() 7const wafrnMediaRegex = 8 /\[wafrnmediaid="[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}"\]/gm 9const youtubeRegex = 10 /((?:https?:\/\/)?(www.|m.)?(youtube(\-nocookie)?\.com|youtu\.be)\/(v\/|watch\?v=|embed\/)?([\S]{11}))([^\S]|\?[\S]*|\&[\S]*|\b)/g 11 12function getURL(urlString: string): URL { 13 let res = new URL(completeEnvironment.frontendEnvironment.frontUrl) 14 try { 15 res = new URL(urlString) 16 } catch (error) { 17 console.log('Invalid url: ' + urlString) 18 } 19 return res 20} 21 22function emojiToHtml(emoji: Emoji): string { 23 return `<img class="post-emoji" src="${ 24 completeEnvironment.frontendEnvironment.externalCacheurl + 25 (emoji.external 26 ? encodeURIComponent(emoji.url) 27 : encodeURIComponent(completeEnvironment.frontendEnvironment.baseMediaUrl + emoji.url)) 28 }" title="${emoji.name}" alt="${emoji.name}">` 29} 30 31export function getPostHtml( 32 post: Post, 33 tags: string[] = [ 34 'b', 35 'i', 36 'u', 37 'a', 38 's', 39 'del', 40 'span', 41 'br', 42 'p', 43 'h1', 44 'h2', 45 'h3', 46 'h4', 47 'h5', 48 'h6', 49 'pre', 50 'strong', 51 'em', 52 'ul', 53 'li', 54 'marquee', 55 'font', 56 'blockquote', 57 'code', 58 'hr', 59 'ol', 60 'q', 61 'small', 62 'sub', 63 'sup', 64 'table', 65 'tr', 66 'td', 67 'th', 68 'cite', 69 'colgroup', 70 'col', 71 'dl', 72 'dt', 73 'dd', 74 'caption', 75 'details', 76 'summary', 77 'mark', 78 'tbody', 79 'tfoot', 80 'thead' 81 ] 82): string { 83 const content = post.content 84 let sanitized = sanitizeHtml(content, { 85 allowedTags: tags, 86 allowedAttributes: { 87 a: ['href', 'title', 'target'], 88 col: ['span', 'visibility'], 89 colgroup: ['width', 'visibility', 'background', 'border'], 90 hr: ['style'], 91 span: ['title', 'style', 'lang'], 92 th: ['colspan', 'rowspan'], 93 '*': ['title', 'lang', 'style'] 94 }, 95 allowedStyles: { 96 '*': { 97 'aspect-ratio': [new RegExp('.*')], 98 background: [new RegExp('.*')], 99 'background-color': [new RegExp('.*')], 100 border: [new RegExp('.*')], 101 'border-bottom': [new RegExp('.*')], 102 'border-bottom-color': [new RegExp('.*')], 103 'border-bottom-left-radius': [new RegExp('.*')], 104 'border-bottom-right-radius': [new RegExp('.*')], 105 'border-bottom-style': [new RegExp('.*')], 106 'border-bottom-width': [new RegExp('.*')], 107 'border-collapse': [new RegExp('.*')], 108 'border-color': [new RegExp('.*')], 109 'border-end-end-radius': [new RegExp('.*')], 110 'border-end-start-radius': [new RegExp('.*')], 111 'border-inline': [new RegExp('.*')], 112 'border-inline-color': [new RegExp('.*')], 113 'border-inline-end': [new RegExp('.*')], 114 'border-inline-end-color': [new RegExp('.*')], 115 'border-inline-end-style': [new RegExp('.*')], 116 'border-inline-end-width': [new RegExp('.*')], 117 'border-inline-start': [new RegExp('.*')], 118 'border-inline-start-color': [new RegExp('.*')], 119 'border-inline-start-style': [new RegExp('.*')], 120 'border-inline-start-width': [new RegExp('.*')], 121 'border-inline-style': [new RegExp('.*')], 122 'border-inline-width': [new RegExp('.*')], 123 'border-left': [new RegExp('.*')], 124 'border-left-color': [new RegExp('.*')], 125 'border-left-style': [new RegExp('.*')], 126 'border-left-width': [new RegExp('.*')], 127 'border-radius': [new RegExp('.*')], 128 'border-right': [new RegExp('.*')], 129 'border-right-color': [new RegExp('.*')], 130 'border-right-style': [new RegExp('.*')], 131 'border-right-width': [new RegExp('.*')], 132 'border-spacing': [new RegExp('.*')], 133 'border-start-end-radius': [new RegExp('.*')], 134 'border-start-start-radius': [new RegExp('.*')], 135 'border-style': [new RegExp('.*')], 136 'border-top': [new RegExp('.*')], 137 'border-top-color': [new RegExp('.*')], 138 'border-top-left-radius': [new RegExp('.*')], 139 'border-top-right-radius': [new RegExp('.*')], 140 'border-top-style': [new RegExp('.*')], 141 'border-top-width': [new RegExp('.*')], 142 'border-width': [new RegExp('.*')], 143 bottom: [new RegExp('.*')], 144 color: [new RegExp('.*')], 145 direction: [new RegExp('.*')], 146 'empty-cells': [new RegExp('.*')], 147 font: [new RegExp('.*')], 148 'font-family': [new RegExp('.*')], 149 'font-size': [new RegExp('.*')], 150 'font-size-adjust': [new RegExp('.*')], 151 'font-style': [new RegExp('.*')], 152 'font-variant': [new RegExp('.*')], 153 'font-variant-caps': [new RegExp('.*')], 154 'font-weight': [new RegExp('.*')], 155 height: [new RegExp('.*')], 156 'initial-letter': [new RegExp('.*')], 157 'inline-size': [new RegExp('.*')], 158 left: [new RegExp('.*')], 159 'left-spacing': [new RegExp('.*')], 160 'list-style': [new RegExp('.*')], 161 'list-style-position': [new RegExp('.*')], 162 'list-style-type': [new RegExp('.*')], 163 margin: [new RegExp('.*')], 164 'margin-bottom': [new RegExp('.*')], 165 'margin-inline': [new RegExp('.*')], 166 'margin-inline-end': [new RegExp('.*')], 167 'margin-inline-start': [new RegExp('.*')], 168 'margin-left': [new RegExp('.*')], 169 'margin-right': [new RegExp('.*')], 170 'margin-top': [new RegExp('.*')], 171 opacity: [new RegExp('.*')], 172 padding: [new RegExp('.*')], 173 'padding-bottom': [new RegExp('.*')], 174 'padding-inline': [new RegExp('.*')], 175 'padding-inline-end': [new RegExp('.*')], 176 'padding-inline-right': [new RegExp('.*')], 177 'padding-left': [new RegExp('.*')], 178 'padding-right': [new RegExp('.*')], 179 'padding-top': [new RegExp('.*')], 180 quotes: [new RegExp('.*')], 181 rotate: [new RegExp('.*')], 182 'tab-size': [new RegExp('.*')], 183 'table-layout': [new RegExp('.*')], 184 'text-align': [new RegExp('.*')], 185 'text-align-last': [new RegExp('.*')], 186 'text-decoration': [new RegExp('.*')], 187 'text-decoration-color': [new RegExp('.*')], 188 'text-decoration-line': [new RegExp('.*')], 189 'text-decoration-style': [new RegExp('.*')], 190 'text-decoration-thickness': [new RegExp('.*')], 191 'text-emphasis': [new RegExp('.*')], 192 'text-emphasis-color': [new RegExp('.*')], 193 'text-emphasis-position': [new RegExp('.*')], 194 'text-emphasis-style': [new RegExp('.*')], 195 'text-indent': [new RegExp('.*')], 196 'text-justify': [new RegExp('.*')], 197 'text-orientation': [new RegExp('.*')], 198 'text-shadow': [new RegExp('.*')], 199 'text-transform': [new RegExp('.*')], 200 'text-underline-offset': [new RegExp('.*')], 201 'text-underline-position': [new RegExp('.*')], 202 top: [new RegExp('.*')], 203 transform: [new RegExp('.*')], 204 visibility: [new RegExp('.*')], 205 width: [new RegExp('.*')], 206 'word-break': [new RegExp('.*')], 207 'word-spacing': [new RegExp('.*')], 208 'word-wrap': [new RegExp('.*')], 209 'writing-mode': [new RegExp('.*')] 210 } 211 } 212 }) 213 // we remove stuff like img and script tags. we only allow certain stuff. 214 const parsedAsHTML = parser.parseFromString(sanitized, 'text/html') 215 const links = parsedAsHTML.getElementsByTagName('a') 216 const mentionedRemoteIds = post.mentionPost ? post.mentionPost?.map((elem) => elem.remoteId) : [] 217 const mentionRemoteUrls = post.mentionPost ? post.mentionPost?.map((elem) => elem.url) : [] 218 const mentionedHosts = post.mentionPost 219 ? post.mentionPost?.map( 220 (elem) => getURL(elem.remoteId ? elem.remoteId : 'https://adomainthatdoesnotexist.google.com').hostname 221 ) 222 : [] 223 const hostUrl = getURL(completeEnvironment.frontendEnvironment.frontUrl).hostname 224 Array.from(links).forEach((link) => { 225 const youtubeMatch = link.href.matchAll(youtubeRegex) 226 if (link.innerText === link.href && youtubeMatch) { 227 // NOTE: Since this should not be part of the image Viewer, we have to add then no-viewer class to be checked for later 228 Array.from(youtubeMatch).forEach((youtubeString) => { 229 link.innerHTML = `<div class="watermark"><!-- Watermark container --><div class="watermark__inner"><!-- The watermark --><div class="watermark__body"><img alt="youtube logo" class="yt-watermark no-viewer" loading="lazy" src="/assets/img/youtube_logo.png"></div></div><img class="yt-thumbnail" src="${ 230 completeEnvironment.frontendEnvironment.externalCacheurl + 231 encodeURIComponent(`https://img.youtube.com/vi/${youtubeString[6]}/hqdefault.jpg`) 232 }" loading="lazy" alt="Thumbnail for video"></div>` 233 }) 234 } 235 // replace mentioned users with wafrn version of profile. 236 // TODO not all software links to mentionedProfile 237 if (mentionedRemoteIds.includes(link.href)) { 238 if (post.mentionPost) { 239 const mentionedUser = post.mentionPost.find((elem) => elem.remoteId === link.href) 240 if (mentionedUser) { 241 link.href = `${completeEnvironment.frontendEnvironment.frontUrl}/blog/${mentionedUser.url}` 242 link.classList.add('mention') 243 link.classList.add('remote-mention') 244 } 245 } 246 } 247 const linkAsUrl: URL = getURL(link.href) 248 if (mentionedHosts.includes(linkAsUrl.hostname) || linkAsUrl.hostname === hostUrl) { 249 const sanitizedContent = sanitizeHtml(link.innerHTML, { 250 allowedTags: [] 251 }) 252 const isUserTag = sanitizedContent.startsWith('@') 253 const isRemoteUser = mentionRemoteUrls.includes(`${sanitizedContent}@${linkAsUrl.hostname}`) 254 const isLocalUser = mentionRemoteUrls.includes(`${sanitizedContent}`) 255 const isLocalUserLink = 256 linkAsUrl.hostname === hostUrl && 257 (linkAsUrl.pathname.startsWith('/blog') || linkAsUrl.pathname.startsWith('/fediverse/blog')) 258 259 if (isUserTag) { 260 link.classList.add('mention') 261 262 if (isRemoteUser) { 263 // Remote blog, mirror to local blog 264 link.href = `/blog/${sanitizedContent}@${linkAsUrl.hostname}` 265 link.classList.add('remote-mention') 266 } 267 268 if (isLocalUser) { 269 //link.href = `/blog/${sanitizedContent}` 270 link.classList.add('mention') 271 link.classList.add('local-mention') 272 } 273 } 274 // Also tag local user links for user styles 275 if (isLocalUserLink) { 276 link.classList.add('local-user-link') 277 } 278 } 279 link.target = '_blank' 280 sanitized = parsedAsHTML.documentElement.innerHTML 281 }) 282 283 sanitized = sanitized.replaceAll(wafrnMediaRegex, '') 284 285 let emojiset = new Set<string>() 286 post.emojis.forEach((emoji) => { 287 // Post can include the same emoji more than once, causing recursive behaviour with alt/title text 288 if (emojiset.has(emoji.name)) return 289 emojiset.add(emoji.name) 290 const strToReplace = emoji.name.startsWith(':') ? emoji.name : `:${emoji.name}:` 291 sanitized = sanitized.replaceAll(strToReplace, emojiToHtml(emoji)) 292 }) 293 return sanitized 294}