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 '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}