unoffical wafrn mirror
wafrn.net
atproto
social-network
activitypub
1import { Op } from 'sequelize'
2import { Media, Post, PostTag, sequelize, User } from '../../models/index.js'
3import { completeEnvironment } from '../backendOptions.js'
4import { fediverseTag } from '../../interfaces/fediverse/tags.js'
5import { activityPubObject } from '../../interfaces/fediverse/activityPubObject.js'
6import { emojiToAPTag } from './emojiToAPTag.js'
7import { getPostReplies } from './getPostReplies.js'
8import { getPostAndUserFromPostId } from '../cacheGetters/getPostAndUserFromPostId.js'
9import { logger } from '../logger.js'
10import { Privacy } from '../../models/post.js'
11import { redisCache } from '../redis.js'
12
13async function postToJSONLD(postId: string): Promise<activityPubObject | undefined> {
14 let resFromCacheString = await redisCache.get('postToJsonLD:' + postId)
15 if (resFromCacheString) {
16 return JSON.parse(resFromCacheString) as activityPubObject
17 }
18 const cacheData = await getPostAndUserFromPostId(postId)
19 const post = cacheData.data
20 const localUser = post.user
21 const userAsker = post.ask?.asker
22 const ask = post.ask
23
24 const stringMyFollowers = `${completeEnvironment.frontendUrl}/fediverse/blog/${localUser.url.toLowerCase()}/followers`
25 const dbMentions = post.mentionPost
26 let mentionedUsers: string[] = []
27
28 if (dbMentions) {
29 mentionedUsers = dbMentions.filter((elem: any) => elem.remoteInbox).map((elem: any) => elem.remoteId)
30 }
31 let parentPostString = null
32 let quotedPostString = null
33 const conversationString = `${completeEnvironment.frontendUrl}/fediverse/conversation/${post.id}`
34
35 if (post.parentId) {
36 let dbPost = (await getPostAndUserFromPostId(post.parentId)).data
37
38 const ancestorIdsQuery = await sequelize.query(
39 `SELECT "ancestorId" FROM "postsancestors" where "postsId" = '${post.parentId}'`
40 )
41 let ancestors: Post[] = []
42 const ancestorIds: string[] = ancestorIdsQuery[0].map((elem: any) => elem.ancestorId)
43 if (ancestorIds.length > 0) {
44 ancestors = await Post.findAll({
45 include: [
46 {
47 model: User,
48 as: 'user',
49 attributes: ['url']
50 }
51 ],
52 where: {
53 id: {
54 [Op.in]: ancestorIds
55 }
56 },
57 order: [['createdAt', 'DESC']]
58 })
59 if (post.bskyDid) {
60 // we do same check for all parents
61
62 const parentsUsers = ancestors.map((elem) => elem.user)
63 if (parentsUsers.some((elem) => elem.isBlueskyUser)) {
64 return undefined
65 }
66 }
67 }
68 for await (const ancestor of ancestors) {
69 if (
70 dbPost &&
71 dbPost.content === '' &&
72 dbPost.hierarchyLevel !== 0 &&
73 dbPost.postTags.length == 0 &&
74 dbPost.medias.length == 0 &&
75 dbPost.quoted.length == 0 && // fix this this is still dirty
76 dbPost.content_warning.length == 0
77 ) {
78 // TODO optimize this.
79 // yes this is still optimizable but we are no longer using a while that could infinite loop
80 // and also there are some checks in this function. so its ok ish
81 // but still
82 dbPost = (await getPostAndUserFromPostId(ancestor.id)).data
83 } else {
84 break
85 }
86 }
87 parentPostString = dbPost?.remotePostId
88 ? dbPost.remotePostId
89 : `${completeEnvironment.frontendUrl}/fediverse/post/${dbPost ? dbPost.id : post.parentId}`
90 }
91 const postMedias = await post.medias
92 let processedContent = post.content
93 const wafrnMediaRegex =
94 /\[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
95
96 // we remove the wafrnmedia from the post for the outside world, as they get this on the attachments
97 processedContent = processedContent.replaceAll(wafrnMediaRegex, '')
98 if (ask) {
99 processedContent = `<p>${getUserName(userAsker)} <a href="${completeEnvironment.frontendUrl + '/fediverse/post/' + post.id
100 }">asked</a> </p> <blockquote>${ask.question}</blockquote> ${processedContent}`
101 }
102 const mentions: string[] = post.mentionPost.map((elem: any) => elem.id)
103 const fediMentions: fediverseTag[] = []
104 const fediTags: fediverseTag[] = []
105 let tagsAndQuotes = '<br>'
106 const quotedPosts = post.quoted
107 if (quotedPosts && quotedPosts.length > 0) {
108 const mainQuotedPost = quotedPosts[0]
109 quotedPostString = getPostUrlForQuote(mainQuotedPost)
110 quotedPosts.forEach((quotedPost: any) => {
111 const postUrl = getPostUrlForQuote(quotedPost)
112 tagsAndQuotes = tagsAndQuotes + `<br>RE: <a href="${postUrl}">${postUrl}</a><br>`
113 if (!postUrl.startsWith('https://bsky.app/')) {
114 fediTags.push({
115 type: 'Link',
116 mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
117 name: `RE: ${postUrl}`,
118 href: postUrl
119 })
120 }
121 })
122 }
123 for await (const tag of post.postTags) {
124 const externalTagName = tag.tagName.replaceAll('"', "'").replaceAll(' ', '-')
125 const link = `${completeEnvironment.frontendUrl}/dashboard/search/${encodeURIComponent(tag.tagName)}`
126 tagsAndQuotes = `${tagsAndQuotes}<small><a class="hashtag" data-tag="post" href="${link}" rel="tag ugc">#${externalTagName}</a></small> `
127 fediTags.push({
128 type: 'Hashtag',
129 name: `#${externalTagName}`,
130 href: link
131 })
132 fediTags.push({
133 type: 'WafrnHashtag',
134 href: link,
135 name: tag.tagName.replaceAll('"', "'")
136 })
137 }
138 for await (const userId of mentions) {
139 const user =
140 (await User.findOne({ where: { id: userId } })) ||
141 ((await User.findOne({ where: { url: completeEnvironment.deletedUser } })) as User)
142 const url = user.fullHandle
143 const remoteId = user.fullFediverseUrl
144 if (remoteId) {
145 fediMentions.push({
146 type: 'Mention',
147 name: url,
148 href: remoteId
149 })
150 }
151 }
152
153 let contentWarning = false
154 postMedias.forEach((media: any) => {
155 if (media.NSFW) {
156 contentWarning = true
157 }
158 })
159
160 const emojis = post.emojis
161
162 if (ask) {
163 fediTags.push({
164 type: 'AskQuestion',
165 name: ask.question,
166 actor: userAsker
167 ? userAsker.remoteId
168 ? userAsker.remoteId
169 : completeEnvironment.frontendUrl + '/fediverse/blog/' + userAsker.url
170 : 'anonymous'
171 })
172 }
173
174 const lineBreaksAtEndRegex = /\s*(<br\s*\/?>)+\s*$/g
175
176 const usersToSend = getToAndCC(post.privacy, mentionedUsers, stringMyFollowers)
177 const actorUrl = `${completeEnvironment.frontendUrl}/fediverse/blog/${localUser.url.toLowerCase()}`
178 let misskeyQuoteURL = quotedPostString
179 if (misskeyQuoteURL?.startsWith('https://bsky.app/')) {
180 misskeyQuoteURL = null
181 }
182 let postAsJSONLD: activityPubObject = {
183 '@context': [
184 'https://www.w3.org/ns/activitystreams',
185 `${completeEnvironment.frontendUrl}/contexts/litepub-0.1.jsonld`
186 ],
187 id: `${completeEnvironment.frontendUrl}/fediverse/activity/post/${post.id}`,
188 type: 'Create',
189 actor: actorUrl,
190 published: new Date(post.createdAt).toISOString(),
191 to: usersToSend.to,
192 cc: usersToSend.cc,
193 object: {
194 id: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`,
195 actor: actorUrl,
196 type: 'Note',
197 summary: post.content_warning ? post.content_warning : '',
198 inReplyTo: parentPostString,
199 published: new Date(post.createdAt).toISOString(),
200 updated: new Date(post.updatedAt).toISOString(),
201 url: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`,
202 attributedTo: `${completeEnvironment.frontendUrl}/fediverse/blog/${localUser.url.toLowerCase()}`,
203 to: usersToSend.to,
204 cc: usersToSend.cc,
205 sensitive: !!post.content_warning || contentWarning,
206 atomUri: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`,
207 inReplyToAtomUri: parentPostString,
208 quote: misskeyQuoteURL,
209 quoteUrl: misskeyQuoteURL,
210 _misksey_quote: misskeyQuoteURL,
211 quoteUri: misskeyQuoteURL,
212 // conversation: conversationString,
213 content: (processedContent + tagsAndQuotes).replace(lineBreaksAtEndRegex, ''),
214 attachment: postMedias
215 ?.sort((a: Media, b: Media) => a.mediaOrder - b.mediaOrder)
216 .map((media: Media) => {
217 const extension = media.url.split('.')[media.url.split('.').length - 1].toLowerCase()
218 return {
219 type: 'Document',
220 mediaType: media.mediaType,
221 url: media.external ? media.url : completeEnvironment.mediaUrl + media.url,
222 sensitive: media.NSFW ? true : false,
223 name: media.description
224 }
225 }),
226 tag: fediMentions.concat(fediTags).concat(emojis.map((emoji: any) => emojiToAPTag(emoji))),
227 replies: {
228 id: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}/replies`,
229 type: 'Collection',
230 first: {
231 type: 'CollectionPage',
232 partOf: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}/replies`,
233 next: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}/replies?page=1`,
234 items: []
235 }
236 }
237 }
238 }
239 const newObject: any = {}
240 const objKeys = Object.keys(postAsJSONLD.object)
241 objKeys.forEach((key) => {
242 if (postAsJSONLD.object[key]) {
243 newObject[key] = postAsJSONLD.object[key]
244 }
245 })
246 postAsJSONLD.object = newObject
247 if (
248 post.content === '' &&
249 post.postTags.length === 0 &&
250 post.medias.length === 0 &&
251 post.quoted.length === 0 &&
252 post.content_warning == 0
253 ) {
254 postAsJSONLD = {
255 '@context': 'https://www.w3.org/ns/activitystreams',
256 id: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`,
257 type: 'Announce',
258 actor: `${completeEnvironment.frontendUrl}/fediverse/blog/${localUser.url.toLowerCase()}`,
259 published: new Date(post.createdAt).toISOString(),
260 to:
261 post.privacy / 1 === Privacy.DirectMessage
262 ? mentionedUsers
263 : post.privacy / 1 === Privacy.Public
264 ? ['https://www.w3.org/ns/activitystreams#Public']
265 : [stringMyFollowers],
266 cc: [`${completeEnvironment.frontendUrl}/fediverse/blog/${localUser.url.toLowerCase()}`, stringMyFollowers],
267 object: parentPostString
268 }
269 }
270 await redisCache.set('postToJsonLD:' + postId, JSON.stringify(postAsJSONLD), 'EX', 300)
271 return postAsJSONLD
272}
273
274function getToAndCC(
275 privacy: number,
276 mentionedUsers: string[],
277 stringMyFollowers: string
278): { to: string[]; cc: string[] } {
279 let to: string[] = []
280 let cc: string[] = []
281 switch (privacy) {
282 case 0: {
283 to = ['https://www.w3.org/ns/activitystreams#Public', stringMyFollowers, ...mentionedUsers]
284 cc = mentionedUsers
285 break
286 }
287 case 1: {
288 to = [stringMyFollowers, ...mentionedUsers]
289 cc = []
290 break
291 }
292 case 3: {
293 to = [stringMyFollowers, ...mentionedUsers]
294 cc = ['https://www.w3.org/ns/activitystreams#Public']
295 break
296 }
297 default: {
298 ; (to = mentionedUsers), (cc = [])
299 }
300 }
301 return {
302 to,
303 cc
304 }
305}
306
307// stolen I mean inspired by https://stackoverflow.com/questions/2970525/converting-a-string-with-spaces-into-camel-case
308function camelize(str: string): string {
309 return str.replace(/(?:^\w|[A-Z]|\b\w|\s+)/g, function (match, index) {
310 if (+match === 0) return '' // or if (/\s+/.test(match)) for white spaces
311 return index === 0 ? match.toLowerCase() : match.toUpperCase()
312 })
313}
314
315function getUserName(user?: { url: string }): string {
316 let res = user ? '@' + user.url + '@' + completeEnvironment.instanceUrl : 'anonymous'
317 if (user?.url.startsWith('@')) {
318 res = user.url
319 }
320 return res
321}
322
323function getPostUrlForQuote(post: any): string {
324 const isPostFromFedi = !!post.remotePostId
325 let res = `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`
326 if (post.isRemoteBlueskyPost) {
327 const parts = post.bskyUri.split('/app.bsky.feed.post/')
328 const userDid = parts[0].split('at://')[1]
329 res = `https://bsky.app/profile/${userDid}/post/${parts[1]}`
330 } else if (isPostFromFedi) {
331 res = post.remotePostId
332 }
333 return res
334}
335
336export { postToJSONLD, getPostUrlForQuote }