unoffical wafrn mirror
wafrn.net
atproto
social-network
activitypub
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}