unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at angular21 298 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 'ruby', 82 'rt', 83 'rp' 84 ] 85): string { 86 const content = post.content 87 let sanitized = sanitizeHtml(content, { 88 allowedTags: tags, 89 allowedAttributes: { 90 a: ['href', 'title', 'target'], 91 col: ['span', 'visibility'], 92 colgroup: ['width', 'visibility', 'background', 'border'], 93 hr: ['style'], 94 span: ['title', 'style', 'lang'], 95 th: ['colspan', 'rowspan'], 96 marquee: ['behavior', 'bgcolor', 'direction', 'loop', 'height', 'width', 'scrolldelay'], 97 '*': ['title', 'lang', 'style'] 98 }, 99 allowedStyles: { 100 '*': { 101 'aspect-ratio': [new RegExp('.*')], 102 background: [new RegExp('.*')], 103 'background-color': [new RegExp('.*')], 104 border: [new RegExp('.*')], 105 'border-bottom': [new RegExp('.*')], 106 'border-bottom-color': [new RegExp('.*')], 107 'border-bottom-left-radius': [new RegExp('.*')], 108 'border-bottom-right-radius': [new RegExp('.*')], 109 'border-bottom-style': [new RegExp('.*')], 110 'border-bottom-width': [new RegExp('.*')], 111 'border-collapse': [new RegExp('.*')], 112 'border-color': [new RegExp('.*')], 113 'border-end-end-radius': [new RegExp('.*')], 114 'border-end-start-radius': [new RegExp('.*')], 115 'border-inline': [new RegExp('.*')], 116 'border-inline-color': [new RegExp('.*')], 117 'border-inline-end': [new RegExp('.*')], 118 'border-inline-end-color': [new RegExp('.*')], 119 'border-inline-end-style': [new RegExp('.*')], 120 'border-inline-end-width': [new RegExp('.*')], 121 'border-inline-start': [new RegExp('.*')], 122 'border-inline-start-color': [new RegExp('.*')], 123 'border-inline-start-style': [new RegExp('.*')], 124 'border-inline-start-width': [new RegExp('.*')], 125 'border-inline-style': [new RegExp('.*')], 126 'border-inline-width': [new RegExp('.*')], 127 'border-left': [new RegExp('.*')], 128 'border-left-color': [new RegExp('.*')], 129 'border-left-style': [new RegExp('.*')], 130 'border-left-width': [new RegExp('.*')], 131 'border-radius': [new RegExp('.*')], 132 'border-right': [new RegExp('.*')], 133 'border-right-color': [new RegExp('.*')], 134 'border-right-style': [new RegExp('.*')], 135 'border-right-width': [new RegExp('.*')], 136 'border-spacing': [new RegExp('.*')], 137 'border-start-end-radius': [new RegExp('.*')], 138 'border-start-start-radius': [new RegExp('.*')], 139 'border-style': [new RegExp('.*')], 140 'border-top': [new RegExp('.*')], 141 'border-top-color': [new RegExp('.*')], 142 'border-top-left-radius': [new RegExp('.*')], 143 'border-top-right-radius': [new RegExp('.*')], 144 'border-top-style': [new RegExp('.*')], 145 'border-top-width': [new RegExp('.*')], 146 'border-width': [new RegExp('.*')], 147 bottom: [new RegExp('.*')], 148 color: [new RegExp('.*')], 149 direction: [new RegExp('.*')], 150 'empty-cells': [new RegExp('.*')], 151 font: [new RegExp('.*')], 152 'font-family': [new RegExp('.*')], 153 'font-size': [new RegExp('.*')], 154 'font-size-adjust': [new RegExp('.*')], 155 'font-style': [new RegExp('.*')], 156 'font-variant': [new RegExp('.*')], 157 'font-variant-caps': [new RegExp('.*')], 158 'font-weight': [new RegExp('.*')], 159 height: [new RegExp('.*')], 160 'initial-letter': [new RegExp('.*')], 161 'inline-size': [new RegExp('.*')], 162 left: [new RegExp('.*')], 163 'left-spacing': [new RegExp('.*')], 164 'list-style': [new RegExp('.*')], 165 'list-style-position': [new RegExp('.*')], 166 'list-style-type': [new RegExp('.*')], 167 margin: [new RegExp('.*')], 168 'margin-bottom': [new RegExp('.*')], 169 'margin-inline': [new RegExp('.*')], 170 'margin-inline-end': [new RegExp('.*')], 171 'margin-inline-start': [new RegExp('.*')], 172 'margin-left': [new RegExp('.*')], 173 'margin-right': [new RegExp('.*')], 174 'margin-top': [new RegExp('.*')], 175 opacity: [new RegExp('.*')], 176 padding: [new RegExp('.*')], 177 'padding-bottom': [new RegExp('.*')], 178 'padding-inline': [new RegExp('.*')], 179 'padding-inline-end': [new RegExp('.*')], 180 'padding-inline-right': [new RegExp('.*')], 181 'padding-left': [new RegExp('.*')], 182 'padding-right': [new RegExp('.*')], 183 'padding-top': [new RegExp('.*')], 184 quotes: [new RegExp('.*')], 185 rotate: [new RegExp('.*')], 186 'tab-size': [new RegExp('.*')], 187 'table-layout': [new RegExp('.*')], 188 'text-align': [new RegExp('.*')], 189 'text-align-last': [new RegExp('.*')], 190 'text-decoration': [new RegExp('.*')], 191 'text-decoration-color': [new RegExp('.*')], 192 'text-decoration-line': [new RegExp('.*')], 193 'text-decoration-style': [new RegExp('.*')], 194 'text-decoration-thickness': [new RegExp('.*')], 195 'text-emphasis': [new RegExp('.*')], 196 'text-emphasis-color': [new RegExp('.*')], 197 'text-emphasis-position': [new RegExp('.*')], 198 'text-emphasis-style': [new RegExp('.*')], 199 'text-indent': [new RegExp('.*')], 200 'text-justify': [new RegExp('.*')], 201 'text-orientation': [new RegExp('.*')], 202 'text-shadow': [new RegExp('.*')], 203 'text-transform': [new RegExp('.*')], 204 'text-underline-offset': [new RegExp('.*')], 205 'text-underline-position': [new RegExp('.*')], 206 top: [new RegExp('.*')], 207 transform: [new RegExp('.*')], 208 visibility: [new RegExp('.*')], 209 width: [new RegExp('.*')], 210 'word-break': [new RegExp('.*')], 211 'word-spacing': [new RegExp('.*')], 212 'word-wrap': [new RegExp('.*')], 213 'writing-mode': [new RegExp('.*')] 214 } 215 } 216 }) 217 // we remove stuff like img and script tags. we only allow certain stuff. 218 const parsedAsHTML = parser.parseFromString(sanitized, 'text/html') 219 const links = parsedAsHTML.getElementsByTagName('a') 220 const mentionedRemoteIds = post.mentionPost ? post.mentionPost?.map((elem) => elem.remoteId) : [] 221 const mentionRemoteUrls = post.mentionPost ? post.mentionPost?.map((elem) => elem.url) : [] 222 const mentionedHosts = post.mentionPost 223 ? post.mentionPost?.map( 224 (elem) => getURL(elem.remoteId ? elem.remoteId : 'https://adomainthatdoesnotexist.google.com').hostname 225 ) 226 : [] 227 const hostUrl = getURL(completeEnvironment.frontendEnvironment.frontUrl).hostname 228 Array.from(links).forEach((link) => { 229 const youtubeMatch = link.href.matchAll(youtubeRegex) 230 if (link.innerText === link.href && youtubeMatch) { 231 // NOTE: Since this should not be part of the image Viewer, we have to add then no-viewer class to be checked for later 232 Array.from(youtubeMatch).forEach((youtubeString) => { 233 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="${ 234 completeEnvironment.frontendEnvironment.externalCacheurl + 235 encodeURIComponent(`https://img.youtube.com/vi/${youtubeString[6]}/hqdefault.jpg`) 236 }" loading="lazy" alt="Thumbnail for video"></div>` 237 }) 238 } 239 // replace mentioned users with wafrn version of profile. 240 // TODO not all software links to mentionedProfile 241 if (mentionedRemoteIds.includes(link.href)) { 242 if (post.mentionPost) { 243 const mentionedUser = post.mentionPost.find((elem) => elem.remoteId === link.href) 244 if (mentionedUser) { 245 link.href = `${completeEnvironment.frontendEnvironment.frontUrl}/blog/${mentionedUser.url}` 246 link.classList.add('mention') 247 link.classList.add('remote-mention') 248 } 249 } 250 } 251 const linkAsUrl: URL = getURL(link.href) 252 if (mentionedHosts.includes(linkAsUrl.hostname) || linkAsUrl.hostname === hostUrl) { 253 const sanitizedContent = sanitizeHtml(link.innerHTML, { 254 allowedTags: [] 255 }) 256 const isUserTag = sanitizedContent.startsWith('@') 257 const isRemoteUser = mentionRemoteUrls.includes(`${sanitizedContent}@${linkAsUrl.hostname}`) 258 const isLocalUser = mentionRemoteUrls.includes(`${sanitizedContent}`) 259 const isLocalUserLink = 260 linkAsUrl.hostname === hostUrl && 261 (linkAsUrl.pathname.startsWith('/blog') || linkAsUrl.pathname.startsWith('/fediverse/blog')) 262 263 if (isUserTag) { 264 link.classList.add('mention') 265 266 if (isRemoteUser) { 267 // Remote blog, mirror to local blog 268 link.href = `/blog/${sanitizedContent}@${linkAsUrl.hostname}` 269 link.classList.add('remote-mention') 270 } 271 272 if (isLocalUser) { 273 //link.href = `/blog/${sanitizedContent}` 274 link.classList.add('mention') 275 link.classList.add('local-mention') 276 } 277 } 278 // Also tag local user links for user styles 279 if (isLocalUserLink) { 280 link.classList.add('local-user-link') 281 } 282 } 283 link.target = '_blank' 284 sanitized = parsedAsHTML.documentElement.innerHTML 285 }) 286 287 sanitized = sanitized.replaceAll(wafrnMediaRegex, '') 288 289 let emojiset = new Set<string>() 290 post.emojis.forEach((emoji) => { 291 // Post can include the same emoji more than once, causing recursive behaviour with alt/title text 292 if (emojiset.has(emoji.name)) return 293 emojiset.add(emoji.name) 294 const strToReplace = emoji.name.startsWith(':') ? emoji.name : `:${emoji.name}:` 295 sanitized = sanitized.replaceAll(strToReplace, emojiToHtml(emoji)) 296 }) 297 return sanitized 298}