unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at angular21 489 lines 16 kB view raw
1import { Op } from "sequelize"; 2import { 3 Media, 4 Post, 5 PostTag, 6 Quotes, 7 sequelize, 8 User, 9} from "../../models/index.js"; 10import { completeEnvironment } from "../backendOptions.js"; 11import { fediverseTag } from "../../interfaces/fediverse/tags.js"; 12import { activityPubObject } from "../../interfaces/fediverse/activityPubObject.js"; 13import { emojiToAPTag } from "./emojiToAPTag.js"; 14import { getPostReplies } from "./getPostReplies.js"; 15import { getPostAndUserFromPostId } from "../cacheGetters/getPostAndUserFromPostId.js"; 16import { logger } from "../logger.js"; 17import { Privacy } from "../../models/post.js"; 18import { redisCache } from "../redis.js"; 19import { htmlToMfm } from "./htmlToMfm.js"; 20import showdown from "showdown"; 21import { getAllLocalUserIds } from "../cacheGetters/getAllLocalUserIds.js"; 22 23const markdownConverter = new showdown.Converter({ 24 simplifiedAutoLink: true, 25 literalMidWordUnderscores: true, 26 strikethrough: true, 27 simpleLineBreaks: true, 28 openLinksInNewWindow: true, 29 emoji: true, 30 encodeEmails: false, 31}); 32 33async function postToJSONLD( 34 postId: string 35): Promise<activityPubObject | undefined> { 36 let resFromCacheString = await redisCache.get("postToJsonLD:" + postId); 37 let askContent = ""; 38 if (resFromCacheString) { 39 return JSON.parse(resFromCacheString) as activityPubObject; 40 } 41 const cacheData = await getPostAndUserFromPostId(postId); 42 const post = cacheData.data; 43 if (!post) { 44 return undefined; 45 } 46 const localUser = post.user; 47 let userAsker = undefined; 48 const ask = post.ask; 49 if (ask) { 50 userAsker = await User.findByPk(ask.userAsker); 51 } 52 53 const stringMyFollowers = `${completeEnvironment.frontendUrl 54 }/fediverse/blog/${localUser.url.toLowerCase()}/followers`; 55 const dbMentions = post.mentionPost; 56 let mentionedUsers: string[] = []; 57 58 if (dbMentions) { 59 mentionedUsers = dbMentions 60 .filter((elem: any) => elem.remoteInbox) 61 .map((elem: any) => elem.remoteId); 62 } 63 let parentPostString = null; 64 let quotedPostString = null; 65 let quoteAuthorization = null; 66 const conversationString = `${completeEnvironment.frontendUrl}/fediverse/conversation/${post.id}`; 67 68 if (post.parentId) { 69 let dbPost = (await getPostAndUserFromPostId(post.parentId)).data; 70 71 const ancestorIdsQuery = await sequelize.query( 72 `SELECT "ancestorId" FROM "postsancestors" where "postsId" = '${post.parentId}'` 73 ); 74 let ancestors: Post[] = []; 75 const ancestorIds: string[] = ancestorIdsQuery[0].map( 76 (elem: any) => elem.ancestorId 77 ); 78 if (ancestorIds.length > 0) { 79 ancestors = await Post.findAll({ 80 include: [ 81 { 82 model: User, 83 as: "user", 84 attributes: ["url"], 85 }, 86 ], 87 where: { 88 id: { 89 [Op.in]: ancestorIds, 90 }, 91 }, 92 order: [["createdAt", "DESC"]], 93 }); 94 if (post.bskyDid) { 95 // we do same check for all parents 96 97 const parentsUsers = ancestors.map((elem) => elem.user); 98 if ( 99 ancestors.some( 100 (elem) => 101 elem.user.isBlueskyUser && elem.bskyUri && !elem.remotePostId 102 ) 103 ) { 104 return undefined; 105 } 106 } 107 } 108 for await (const ancestor of ancestors) { 109 if ( 110 dbPost && 111 dbPost.content === "" && 112 dbPost.hierarchyLevel !== 0 && 113 dbPost.postTags.length == 0 && 114 dbPost.medias.length == 0 && 115 dbPost.quoted.length == 0 && // fix this this is still dirty 116 dbPost.content_warning.length == 0 117 ) { 118 // TODO optimize this. 119 // yes this is still optimizable but we are no longer using a while that could infinite loop 120 // and also there are some checks in this function. so its ok ish 121 // but still 122 dbPost = (await getPostAndUserFromPostId(ancestor.id)).data; 123 } else { 124 break; 125 } 126 } 127 parentPostString = dbPost?.remotePostId 128 ? dbPost.remotePostId 129 : `${completeEnvironment.frontendUrl}/fediverse/post/${dbPost ? dbPost.id : post.parentId 130 }`; 131 } 132 const postMedias = await post.medias; 133 let processedContent = post.content; 134 const wafrnMediaRegex = 135 /\[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; 136 137 // we remove the wafrnmedia from the post for the outside world, as they get this on the attachments 138 processedContent = processedContent.replaceAll(wafrnMediaRegex, ""); 139 let misskeyContent = 140 markdownConverter.makeHtml(post.markdownContent) || processedContent; 141 142 let misskeyAskContent = ""; 143 144 if (ask) { 145 askContent = `<p>${getUserName(userAsker)} <a href="${completeEnvironment.frontendUrl + "/fediverse/post/" + post.id 146 }">asked</a> </p> <blockquote>${ask.question}</blockquote> `; 147 processedContent = `${askContent} ${processedContent}`; 148 misskeyAskContent = `$[border.style=solid,width=1,radius=6 $[border.color=0000,width=12 <small>${getUserName(userAsker)} [asked](${completeEnvironment.frontendUrl + "/fediverse/post/" + post.id 149 }):</small> 150${await htmlToMfm(ask.question)}]]\n\n`; 151 } 152 const mentions: string[] = post.mentionPost.map((elem: any) => elem.id); 153 const misskeyMentions: string[] = []; 154 const fediMentions: fediverseTag[] = []; 155 const fediTags: fediverseTag[] = []; 156 let tagsAndQuotes = "<br>"; 157 let misskeyTagsAndQuotes = ""; 158 const quotedPosts = post.quoted; 159 160 const lineBreaksAtEndRegex = /\s*(<br\s*\/?>)+\s*$/g; 161 162 if (quotedPosts && quotedPosts.length > 0) { 163 const mainQuotedPost = quotedPosts[0]; 164 quoteAuthorization = ( 165 await Quotes.findOne({ 166 where: { 167 quoterPostId: post.id, 168 }, 169 }) 170 )?.authorizationUrl; 171 quotedPostString = await getPostUrlForQuote(mainQuotedPost); 172 for await (const quotedPost of quotedPosts) { 173 const postUrl = await getPostUrlForQuote(quotedPost); 174 tagsAndQuotes = 175 tagsAndQuotes + `<br>RE: <a href="${postUrl}">${postUrl}</a><br>`; 176 if (!postUrl.startsWith("https://bsky.app/")) { 177 fediTags.push({ 178 type: "Link", 179 mediaType: 180 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 181 name: `RE: ${postUrl}`, 182 href: postUrl, 183 }); 184 } 185 } 186 } 187 tagsAndQuotes = tagsAndQuotes + "<small>"; 188 for await (const tag of post.postTags) { 189 const externalTagName = tag.tagName 190 .replaceAll('"', "'") 191 .replaceAll(" ", "-"); 192 const link = `${completeEnvironment.frontendUrl 193 }/dashboard/search/${encodeURIComponent(tag.tagName)}`; 194 tagsAndQuotes = `${tagsAndQuotes} <a class="hashtag" data-tag="post" href="${link}" rel="tag ugc">#${externalTagName}</a>`; 195 misskeyTagsAndQuotes = `${misskeyTagsAndQuotes} ${tag.tagName.trim().includes(" ") 196 ? "# " + tag.tagName.trim() 197 : "#" + tag.tagName.trim() 198 }`; 199 fediTags.push({ 200 type: "Hashtag", 201 name: `#${externalTagName}`, 202 href: link, 203 }); 204 fediTags.push({ 205 type: "WafrnHashtag", 206 href: link, 207 name: tag.tagName.replaceAll('"', "'"), 208 }); 209 } 210 tagsAndQuotes = tagsAndQuotes + "</small>"; 211 if (tagsAndQuotes === "<br><small></small>") { 212 tagsAndQuotes = ""; 213 } 214 if (tagsAndQuotes.endsWith("<small></small>")) { 215 tagsAndQuotes = tagsAndQuotes.split("<small></small>")[0]; 216 } 217 218 for await (const userId of mentions) { 219 const user = 220 (await User.findOne({ where: { id: userId } })) || 221 ((await User.findOne({ 222 where: { url: completeEnvironment.deletedUser }, 223 })) as User); 224 const url = user.fullHandle; 225 const remoteId = user.fullFediverseUrl; 226 if (remoteId) { 227 fediMentions.push({ 228 type: "Mention", 229 name: url, 230 href: remoteId, 231 }); 232 } 233 if ( 234 !misskeyContent.includes(user.url) && 235 !misskeyAskContent.includes(user.url) 236 ) 237 misskeyMentions.push(url); 238 } 239 misskeyContent = await htmlToMfm( 240 misskeyContent.replace(lineBreaksAtEndRegex, "") 241 ); 242 if (misskeyTagsAndQuotes.length > 0) { 243 misskeyContent = 244 misskeyContent + 245 `\n<small>${await htmlToMfm(misskeyTagsAndQuotes)}</small>`; 246 } 247 const misskeyMentionContent = 248 misskeyMentions.length > 0 ? `${misskeyMentions.join(" ")}\n\n` : ""; 249 250 let contentWarning = false; 251 postMedias.forEach((media: any) => { 252 if (media.NSFW) { 253 contentWarning = true; 254 } 255 }); 256 257 const emojis = post.emojis; 258 259 if (ask) { 260 fediTags.push({ 261 type: "AskQuestion", 262 name: ask.question, 263 representation: askContent, 264 actor: userAsker 265 ? userAsker.remoteId 266 ? userAsker.remoteId 267 : completeEnvironment.frontendUrl + "/fediverse/blog/" + userAsker.url 268 : "anonymous", 269 }); 270 } 271 const usersToSend = getToAndCC( 272 post.privacy, 273 mentionedUsers, 274 stringMyFollowers 275 ); 276 const actorUrl = `${completeEnvironment.frontendUrl 277 }/fediverse/blog/${localUser.url.toLowerCase()}`; 278 const misskeyMarkdown = 279 misskeyMentionContent + misskeyAskContent + misskeyContent; 280 let misskeyQuoteURL = quotedPostString; 281 if (misskeyQuoteURL?.startsWith("https://bsky.app/")) { 282 misskeyQuoteURL = null; 283 } 284 let postAsJSONLD: activityPubObject = { 285 "@context": [ 286 "https://www.w3.org/ns/activitystreams", 287 `${completeEnvironment.frontendUrl}/contexts/litepub-0.1.jsonld`, 288 ], 289 id: `${completeEnvironment.frontendUrl}/fediverse/activity/post/${post.id}`, 290 type: "Create", 291 actor: actorUrl, 292 published: new Date(post.createdAt).toISOString(), 293 to: usersToSend.to, 294 cc: usersToSend.cc, 295 object: { 296 id: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 297 blueskyUri: post.bskyUri, 298 blueskyCid: post.bskyCid, 299 actor: actorUrl, 300 type: "Note", 301 summary: post.content_warning ? post.content_warning : "", 302 inReplyTo: parentPostString, 303 published: new Date(post.createdAt).toISOString(), 304 updated: new Date(post.updatedAt).toISOString(), 305 _misskey_content: misskeyMarkdown, 306 source: { 307 content: misskeyMarkdown, 308 mediaType: "text/x.misskeymarkdown", 309 }, 310 url: post.bskyUri 311 ? [ 312 `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 313 { 314 type: "Link", 315 rel: "alternate", 316 href: post.bskyUri, 317 }, 318 ] 319 : `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 320 attributedTo: `${completeEnvironment.frontendUrl 321 }/fediverse/blog/${localUser.url.toLowerCase()}`, 322 to: usersToSend.to, 323 cc: usersToSend.cc, 324 sensitive: !!post.content_warning || contentWarning, 325 atomUri: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 326 inReplyToAtomUri: parentPostString, 327 quoteUrl: misskeyQuoteURL, 328 _misskey_quote: misskeyQuoteURL, 329 quoteUri: misskeyQuoteURL, 330 // conversation: conversationString, 331 content: (processedContent + tagsAndQuotes).replace( 332 lineBreaksAtEndRegex, 333 "" 334 ), 335 attachment: postMedias 336 ?.sort((a: Media, b: Media) => a.mediaOrder - b.mediaOrder) 337 .map((media: Media) => { 338 const extension = media.url 339 .split(".") 340 [media.url.split(".").length - 1].toLowerCase(); 341 return { 342 type: "Document", 343 mediaType: media.mediaType, 344 url: 345 media.url.startsWith("?cid") || media.external 346 ? completeEnvironment.externalCacheurl + 347 encodeURIComponent(media.url) 348 : completeEnvironment.mediaUrl + media.url, 349 sensitive: media.NSFW ? true : false, 350 name: media.description, 351 }; 352 }), 353 tag: fediMentions 354 .concat(fediTags) 355 .concat(emojis.map((emoji: any) => emojiToAPTag(emoji))), 356 replies: { 357 id: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}/replies`, 358 type: "Collection", 359 first: { 360 type: "CollectionPage", 361 partOf: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}/replies`, 362 next: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}/replies?page=1`, 363 items: [], 364 }, 365 }, 366 interactionPolicy: { 367 canQuote: { 368 automaticApproval: ["https://www.w3.org/ns/activitystreams#Public"], 369 }, 370 // canLike: { automaticApproval: ['https://www.w3.org/ns/activitystreams#Public'] }, 371 // canReply: { automaticApproval: ['https://www.w3.org/ns/activitystreams#Public'] }, 372 // canAnnounce: { automaticApproval: ['https://www.w3.org/ns/activitystreams#Public'] } 373 }, 374 }, 375 }; 376 const newObject: any = {}; 377 const objKeys = Object.keys(postAsJSONLD.object); 378 objKeys.forEach((key) => { 379 if (postAsJSONLD.object[key]) { 380 newObject[key] = postAsJSONLD.object[key]; 381 } 382 }); 383 postAsJSONLD.object = newObject; 384 if ( 385 post.content === "" && 386 post.postTags.length === 0 && 387 post.medias.length === 0 && 388 post.quoted.length === 0 && 389 post.content_warning == 0 390 ) { 391 postAsJSONLD = { 392 "@context": "https://www.w3.org/ns/activitystreams", 393 id: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 394 type: "Announce", 395 actor: `${completeEnvironment.frontendUrl 396 }/fediverse/blog/${localUser.url.toLowerCase()}`, 397 published: new Date(post.createdAt).toISOString(), 398 to: 399 post.privacy / 1 === Privacy.DirectMessage 400 ? mentionedUsers 401 : post.privacy / 1 === Privacy.Public 402 ? ["https://www.w3.org/ns/activitystreams#Public"] 403 : [stringMyFollowers], 404 cc: [ 405 `${completeEnvironment.frontendUrl 406 }/fediverse/blog/${localUser.url.toLowerCase()}`, 407 stringMyFollowers, 408 ], 409 object: parentPostString, 410 }; 411 } 412 await redisCache.set( 413 "postToJsonLD:" + postId, 414 JSON.stringify(postAsJSONLD), 415 "EX", 416 300 417 ); 418 return postAsJSONLD; 419} 420 421function getToAndCC( 422 privacy: number, 423 mentionedUsers: string[], 424 stringMyFollowers: string 425): { to: string[]; cc: string[] } { 426 let to: string[] = []; 427 let cc: string[] = []; 428 switch (privacy) { 429 case 0: { 430 to = [ 431 "https://www.w3.org/ns/activitystreams#Public", 432 stringMyFollowers, 433 ...mentionedUsers, 434 ]; 435 cc = mentionedUsers; 436 break; 437 } 438 case 1: { 439 to = [stringMyFollowers, ...mentionedUsers]; 440 cc = []; 441 break; 442 } 443 case 3: { 444 to = [stringMyFollowers, ...mentionedUsers]; 445 cc = ["https://www.w3.org/ns/activitystreams#Public"]; 446 break; 447 } 448 default: { 449 (to = mentionedUsers), (cc = []); 450 } 451 } 452 return { 453 to, 454 cc, 455 }; 456} 457 458// stolen I mean inspired by https://stackoverflow.com/questions/2970525/converting-a-string-with-spaces-into-camel-case 459function camelize(str: string): string { 460 return str.replace(/(?:^\w|[A-Z]|\b\w|\s+)/g, function (match, index) { 461 if (+match === 0) return ""; // or if (/\s+/.test(match)) for white spaces 462 return index === 0 ? match.toLowerCase() : match.toUpperCase(); 463 }); 464} 465 466function getUserName(user?: User | undefined | null): string { 467 let res = user 468 ? "@" + user.url + "@" + completeEnvironment.instanceUrl 469 : "anonymous"; 470 if (user?.url.startsWith("@")) { 471 res = user.url; 472 } 473 return res; 474} 475 476async function getPostUrlForQuote(post: any): Promise<string> { 477 const isPostFromFedi = !!post.remotePostId; 478 let res = `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`; 479 if (post.bskyUri && !(await getAllLocalUserIds()).includes(post.userId)) { 480 const parts = post.bskyUri.split("/app.bsky.feed.post/"); 481 const userDid = parts[0].split("at://")[1]; 482 res = `https://bsky.app/profile/${userDid}/post/${parts[1]}`; 483 } else if (isPostFromFedi) { 484 res = post.remotePostId; 485 } 486 return res; 487} 488 489export { postToJSONLD, getPostUrlForQuote };