unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at cache-folder-container 577 lines 20 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 { InteractionControl, InteractionControlType, 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, true); 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 stringMyFollowing = `${completeEnvironment.frontendUrl 56 }/fediverse/blog/${localUser.url.toLowerCase()}/following`; 57 const dbMentions = post.mentionPost; 58 let mentionedUsers: string[] = []; 59 60 if (dbMentions) { 61 mentionedUsers = dbMentions 62 .filter((elem: any) => elem.remoteInbox) 63 .map((elem: any) => elem.remoteId); 64 } 65 let parentPostString = null; 66 let quotedPostString = null; 67 let quoteAuthorization = null; 68 const conversationString = `${completeEnvironment.frontendUrl}/fediverse/conversation/${post.id}`; 69 70 if (post.parentId) { 71 let dbPost = (await getPostAndUserFromPostId(post.parentId)).data; 72 73 const ancestorIdsQuery = await sequelize.query( 74 `SELECT "ancestorId" FROM "postsancestors" where "postsId" = '${post.parentId}'` 75 ); 76 let ancestors: Post[] = []; 77 const ancestorIds: string[] = ancestorIdsQuery[0].map( 78 (elem: any) => elem.ancestorId 79 ); 80 if (ancestorIds.length > 0) { 81 ancestors = await Post.findAll({ 82 include: [ 83 { 84 model: User, 85 as: "user", 86 attributes: ["url"], 87 }, 88 ], 89 where: { 90 id: { 91 [Op.in]: ancestorIds, 92 }, 93 }, 94 order: [["createdAt", "DESC"]], 95 }); 96 if (post.bskyDid) { 97 // we do same check for all parents 98 99 const parentsUsers = ancestors.map((elem) => elem.user); 100 if ( 101 ancestors.some( 102 (elem) => 103 elem.user.isBlueskyUser && elem.bskyUri && !elem.remotePostId 104 ) 105 ) { 106 return undefined; 107 } 108 } 109 } 110 for await (const ancestor of ancestors) { 111 if ( 112 dbPost && 113 dbPost.content === "" && 114 dbPost.hierarchyLevel !== 0 && 115 dbPost.postTags.length == 0 && 116 dbPost.medias.length == 0 && 117 dbPost.quoted.length == 0 && // fix this this is still dirty 118 dbPost.content_warning.length == 0 119 ) { 120 // TODO optimize this. 121 // yes this is still optimizable but we are no longer using a while that could infinite loop 122 // and also there are some checks in this function. so its ok ish 123 // but still 124 dbPost = (await getPostAndUserFromPostId(ancestor.id)).data; 125 } else { 126 break; 127 } 128 } 129 parentPostString = dbPost?.remotePostId 130 ? dbPost.remotePostId 131 : `${completeEnvironment.frontendUrl}/fediverse/post/${dbPost ? dbPost.id : post.parentId 132 }`; 133 } 134 const postMedias = await post.medias; 135 let processedContent: string = post.content; 136 const wafrnMediaRegex = 137 /\[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; 138 139 // we remove the wafrnmedia from the post for the outside world, as they get this on the attachments 140 processedContent = processedContent.replaceAll(wafrnMediaRegex, ""); 141 let misskeyContent = 142 markdownConverter.makeHtml(post.markdownContent) || processedContent; 143 144 let misskeyAskContent = ""; 145 146 if (ask) { 147 askContent = `<p>${getUserName(userAsker)} <a href="${completeEnvironment.frontendUrl + "/fediverse/post/" + post.id 148 }">asked</a> </p> <blockquote>${ask.question}</blockquote> `; 149 processedContent = `${askContent} ${processedContent}`; 150 misskeyAskContent = `$[border.style=solid,width=1,radius=6 $[border.color=0000,width=12 ${getUserName(userAsker)} [asked](${completeEnvironment.frontendUrl + "/fediverse/post/" + post.id 151 }): 152${await htmlToMfm(ask.question)}]]\n\n`; 153 } 154 const mentions: string[] = post.mentionPost.map((elem: any) => elem.id); 155 const misskeyMentions: string[] = []; 156 const standardMentions: string[] = []; 157 const fediMentions: fediverseTag[] = []; 158 const fediTags: fediverseTag[] = []; 159 let tagsAndQuotes = "<br>"; 160 let misskeyTagsAndQuotes = ""; 161 const quotedPosts = post.quoted; 162 163 const lineBreaksAtEndRegex = /\s*(<br\s*\/?>)+\s*$/g; 164 165 if (quotedPosts && quotedPosts.length > 0) { 166 const mainQuotedPost = quotedPosts[0]; 167 quoteAuthorization = ( 168 await Quotes.findOne({ 169 where: { 170 quoterPostId: post.id, 171 }, 172 }) 173 )?.authorizationUrl; 174 quotedPostString = await getPostUrlForQuote(mainQuotedPost); 175 for await (const quotedPost of quotedPosts) { 176 const postUrl = await getPostUrlForQuote(quotedPost); 177 tagsAndQuotes = 178 tagsAndQuotes + `<br>RE: <a href="${postUrl}">${postUrl}</a><br>`; 179 if (!postUrl.startsWith("https://bsky.app/")) { 180 fediTags.push({ 181 type: "Link", 182 mediaType: 183 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 184 name: `RE: RE: <a href="${postUrl}">${postUrl}</a><br>`, 185 href: postUrl, 186 }); 187 } else { 188 fediTags.push({ 189 type: "BskyQuote", 190 name: `RE: RE: <a href="${postUrl}">${postUrl}</a><br>`, 191 href: quotedPost.bskyUri, 192 }); 193 misskeyTagsAndQuotes = misskeyTagsAndQuotes + `<br>RE: ${postUrl}` 194 } 195 } 196 } 197 tagsAndQuotes = tagsAndQuotes + "<small>"; 198 for await (const tag of post.postTags) { 199 const externalTagName = tag.tagName 200 .replaceAll('"', "'") 201 .replaceAll(" ", "-"); 202 const link = `${completeEnvironment.frontendUrl 203 }/dashboard/search/${encodeURIComponent(tag.tagName)}`; 204 tagsAndQuotes = `${tagsAndQuotes} <a class="hashtag" data-tag="post" href="${link}" rel="tag ugc">#${externalTagName}</a>`; 205 misskeyTagsAndQuotes = `${misskeyTagsAndQuotes} ${tag.tagName.trim().includes(" ") 206 ? "# " + tag.tagName.trim() 207 : "#" + tag.tagName.trim() 208 }`; 209 fediTags.push({ 210 type: "Hashtag", 211 name: `#${externalTagName}`, 212 href: link, 213 }); 214 fediTags.push({ 215 type: "WafrnHashtag", 216 href: link, 217 name: tag.tagName.replaceAll('"', "'"), 218 }); 219 } 220 tagsAndQuotes = tagsAndQuotes + "</small>"; 221 if (tagsAndQuotes === "<br><small></small>") { 222 tagsAndQuotes = ""; 223 } 224 if (tagsAndQuotes.endsWith("<small></small>")) { 225 tagsAndQuotes = tagsAndQuotes.split("<small></small>")[0]; 226 } 227 228 for await (const userId of mentions) { 229 const user = 230 (await User.findOne({ where: { id: userId } })) || 231 ((await User.findOne({ 232 where: { url: completeEnvironment.deletedUser }, 233 })) as User); 234 const url = user.fullHandle; 235 const remoteId = user.fullFediverseUrl; 236 if (remoteId) { 237 fediMentions.push({ 238 type: "Mention", 239 name: url, 240 href: remoteId, 241 }); 242 } 243 if ( 244 !misskeyContent.includes(user.url) && 245 !misskeyAskContent.includes(user.url) 246 ) 247 misskeyMentions.push(url); 248 standardMentions.push( 249 `<span class="h-card" translate="no"><a href="${user.remoteMentionUrl}" class="u-url mention" rel="nofollow noopener" target="_blank">@<span>${url.substring(1)}</span></a></span>` 250 ) 251 } 252 misskeyContent = await htmlToMfm( 253 misskeyContent.replace(lineBreaksAtEndRegex, "") 254 ); 255 if (misskeyTagsAndQuotes.length > 0) { 256 misskeyContent = 257 misskeyContent + 258 `\n<small>${await htmlToMfm(misskeyTagsAndQuotes)}</small>`; 259 } 260 const misskeyMentionContent = 261 misskeyMentions.length > 0 ? `${misskeyMentions.join(" ")}\n\n` : ""; 262 const standardMentionsContent = standardMentions.length > 0 ? `<p>${standardMentions.join(" ")}</p>`: "" 263 let contentWarning = false; 264 postMedias.forEach((media: any) => { 265 if (media.NSFW) { 266 contentWarning = true; 267 } 268 }); 269 270 const emojis = post.emojis; 271 272 if (ask) { 273 fediTags.push({ 274 type: "AskQuestion", 275 name: ask.question, 276 representation: askContent, 277 actor: userAsker 278 ? userAsker.remoteId 279 ? userAsker.remoteId 280 : completeEnvironment.frontendUrl + "/fediverse/blog/" + userAsker.url 281 : "anonymous", 282 }); 283 } 284 const usersToSend = getToAndCC( 285 post.privacy, 286 mentionedUsers, 287 stringMyFollowers 288 ); 289 const actorUrl = `${completeEnvironment.frontendUrl 290 }/fediverse/blog/${localUser.url.toLowerCase()}`; 291 const misskeyMarkdown = 292 misskeyMentionContent + misskeyAskContent + misskeyContent; 293 let misskeyQuoteURL = quotedPostString; 294 if (misskeyQuoteURL?.startsWith("https://bsky.app/")) { 295 misskeyQuoteURL = null; 296 } 297 let canReply: string[] = []; 298 let canAnnounce: string[] = []; 299 let canLike: string[] = []; 300 301 const canReplyValue: InteractionControlType = post.replyControl; 302 const canAnnounceValue: InteractionControlType = post.reblogControl; 303 const canLikeValue: InteractionControlType = post.likeControl; 304 const publicString = "https://www.w3.org/ns/activitystreams#Public" 305 // canreply: 306 if([InteractionControl.Anyone].includes(canReplyValue)) { 307 canReply.push(publicString) 308 } 309 if([InteractionControl.SameAsOp].includes(canReplyValue)) { 310 canReply.push("sameAsInitialPost") 311 } 312 // mentionedUsers will always bee able to reply 313 canReply = canReply.concat(mentionedUsers) 314 if([InteractionControl.Followers, InteractionControl.FollowersAndFollowing, InteractionControl.FollowersAndMentioned, InteractionControl.FollowersFollowingAndMentioned].includes(canReplyValue)) { 315 canReply = canReply.concat(stringMyFollowers) 316 } 317 if([InteractionControl.Following, InteractionControl.FollowingAndMentioned, InteractionControl.FollowersFollowingAndMentioned, InteractionControl.FollowersAndFollowing].includes(canReplyValue)) { 318 canReply = canReply.concat(stringMyFollowing) 319 } 320 321 if(canAnnounceValue === InteractionControl.Anyone) { 322 canAnnounce.push(publicString) 323 } else { 324 // mentionedUsers 325 if([InteractionControl.MentionedUsersOnly, InteractionControl.FollowersAndMentioned, InteractionControl.FollowingAndMentioned, InteractionControl.FollowersFollowingAndMentioned].includes(canAnnounceValue)) { 326 canAnnounce = canAnnounce.concat(mentionedUsers) 327 } 328 if([InteractionControl.Followers, InteractionControl.FollowersAndFollowing, InteractionControl.FollowersAndMentioned, InteractionControl.FollowersFollowingAndMentioned].includes(canAnnounceValue)) { 329 canAnnounce = canAnnounce.concat(stringMyFollowers) 330 } 331 if([InteractionControl.Following, InteractionControl.FollowingAndMentioned, InteractionControl.FollowersFollowingAndMentioned, InteractionControl.FollowersAndFollowing].includes(canAnnounceValue)) { 332 canAnnounce = canAnnounce.concat(stringMyFollowing) 333 } 334 } 335 336 if(canLikeValue === InteractionControl.Anyone) { 337 canLike.push(publicString) 338 } else { 339 // mentionedUsers 340 if([InteractionControl.MentionedUsersOnly, InteractionControl.FollowersAndMentioned, InteractionControl.FollowingAndMentioned, InteractionControl.FollowersFollowingAndMentioned].includes(canLikeValue)) { 341 canLike = canLike.concat(mentionedUsers) 342 } 343 if([InteractionControl.Followers, InteractionControl.FollowersAndFollowing, InteractionControl.FollowersAndMentioned, InteractionControl.FollowersFollowingAndMentioned].includes(canLikeValue)) { 344 canLike = canLike.concat(stringMyFollowers) 345 } 346 if([InteractionControl.Following, InteractionControl.FollowingAndMentioned, InteractionControl.FollowersFollowingAndMentioned, InteractionControl.FollowersAndFollowing].includes(canLikeValue)) { 347 canLike = canLike.concat(stringMyFollowing) 348 } 349 } 350 351 352 const initialMentionsToRemoveTag: fediverseTag[] = standardMentions.length > 0 ? 353 [ 354 { 355 type: 'WafrnMentionsTextToHide', 356 name: standardMentionsContent 357 } 358 ] 359 : [] 360 let postAsJSONLD: activityPubObject = { 361 "@context": [ 362 "https://www.w3.org/ns/activitystreams", 363 `${completeEnvironment.frontendUrl}/contexts/litepub-0.1.jsonld`, 364 ], 365 id: `${completeEnvironment.frontendUrl}/fediverse/activity/post/${post.id}`, 366 type: "Create", 367 actor: actorUrl, 368 published: new Date(post.createdAt).toISOString(), 369 to: usersToSend.to, 370 cc: usersToSend.cc, 371 object: { 372 id: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 373 blueskyUri: post.bskyUri, 374 blueskyCid: post.bskyCid, 375 actor: actorUrl, 376 type: "Note", 377 summary: post.content_warning ? post.content_warning : "", 378 inReplyTo: parentPostString, 379 published: new Date(post.createdAt).toISOString(), 380 updated: new Date(post.updatedAt).toISOString(), 381 _misskey_content: misskeyMarkdown, 382 source: { 383 content: misskeyMarkdown, 384 mediaType: "text/x.misskeymarkdown", 385 }, 386 url: post.bskyUri 387 ? [ 388 `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 389 { 390 type: "Link", 391 rel: "alternate", 392 href: post.bskyUri, 393 }, 394 ] 395 : `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 396 attributedTo: `${completeEnvironment.frontendUrl 397 }/fediverse/blog/${localUser.url.toLowerCase()}`, 398 to: usersToSend.to, 399 cc: usersToSend.cc, 400 sensitive: !!post.content_warning || contentWarning, 401 atomUri: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 402 inReplyToAtomUri: parentPostString, 403 quoteUrl: misskeyQuoteURL, 404 _misskey_quote: misskeyQuoteURL, 405 quoteUri: misskeyQuoteURL, 406 // conversation: conversationString, 407 // TODO re add standardMentionsContent and delete this comment at some point after more people has updated 408 //content: (standardMentionsContent + processedContent + tagsAndQuotes).replace( 409 content: (processedContent + tagsAndQuotes).replace( 410 411 lineBreaksAtEndRegex, 412 "" 413 ), 414 attachment: postMedias 415 ?.sort((a: Media, b: Media) => a.mediaOrder - b.mediaOrder) 416 .map((media: Media) => { 417 const extension = media.url 418 .split(".") 419 [media.url.split(".").length - 1].toLowerCase(); 420 return { 421 type: "Document", 422 mediaType: media.mediaType, 423 url: 424 media.url.startsWith("?cid") || media.external 425 ? completeEnvironment.externalCacheurl + 426 encodeURIComponent(media.url) 427 : completeEnvironment.mediaUrl + media.url, 428 sensitive: media.NSFW ? true : false, 429 name: media.description, 430 }; 431 }), 432 tag: fediMentions 433 .concat(initialMentionsToRemoveTag) 434 .concat(fediTags) 435 .concat(emojis.map((emoji: any) => emojiToAPTag(emoji))), 436 replies: { 437 id: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}/replies`, 438 type: "Collection", 439 first: { 440 type: "CollectionPage", 441 partOf: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}/replies`, 442 next: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}/replies?page=1`, 443 items: [], 444 }, 445 }, 446 forceDescendentsToUseSameInteractionControls: (post.hierarchyLevel === 1 && post.replyControl != InteractionControl.Anyone ) ? true : undefined, 447 interactionPolicy: { 448 canQuote: { 449 automaticApproval: post.quoteControl === InteractionControl.Anyone ? [ "https://www.w3.org/ns/activitystreams#Public"] : [], 450 }, 451 canLike: { 452 automaticApproval: canLike 453 }, 454 canReply: { 455 automaticApproval: canReply 456 }, 457 canAnnounce: { 458 automaticApproval: canAnnounce 459 } 460 }, 461 }, 462 }; 463 const newObject: any = {}; 464 const objKeys = Object.keys(postAsJSONLD.object); 465 objKeys.forEach((key) => { 466 if (postAsJSONLD.object[key]) { 467 newObject[key] = postAsJSONLD.object[key]; 468 } 469 }); 470 postAsJSONLD.object = newObject; 471 if ( 472 post.content === "" && 473 post.postTags.length === 0 && 474 post.medias.length === 0 && 475 post.quoted.length === 0 && 476 post.content_warning == 0 477 ) { 478 postAsJSONLD = { 479 "@context": "https://www.w3.org/ns/activitystreams", 480 id: `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`, 481 type: "Announce", 482 actor: `${completeEnvironment.frontendUrl 483 }/fediverse/blog/${localUser.url.toLowerCase()}`, 484 published: new Date(post.createdAt).toISOString(), 485 to: 486 post.privacy / 1 === Privacy.DirectMessage 487 ? mentionedUsers 488 : post.privacy / 1 === Privacy.Public 489 ? ["https://www.w3.org/ns/activitystreams#Public"] 490 : [stringMyFollowers], 491 cc: [ 492 `${completeEnvironment.frontendUrl 493 }/fediverse/blog/${localUser.url.toLowerCase()}`, 494 stringMyFollowers, 495 ], 496 object: parentPostString, 497 }; 498 } 499 await redisCache.set( 500 "postToJsonLD:" + postId, 501 JSON.stringify(postAsJSONLD), 502 "EX", 503 300 504 ); 505 return postAsJSONLD; 506} 507 508function getToAndCC( 509 privacy: number, 510 mentionedUsers: string[], 511 stringMyFollowers: string 512): { to: string[]; cc: string[] } { 513 let to: string[] = []; 514 let cc: string[] = []; 515 switch (privacy) { 516 case 0: { 517 to = [ 518 "https://www.w3.org/ns/activitystreams#Public", 519 stringMyFollowers, 520 ...mentionedUsers, 521 ]; 522 cc = mentionedUsers; 523 break; 524 } 525 case 1: { 526 to = [stringMyFollowers, ...mentionedUsers]; 527 cc = []; 528 break; 529 } 530 case 3: { 531 to = [stringMyFollowers, ...mentionedUsers]; 532 cc = ["https://www.w3.org/ns/activitystreams#Public"]; 533 break; 534 } 535 default: { 536 (to = mentionedUsers), (cc = []); 537 } 538 } 539 return { 540 to, 541 cc, 542 }; 543} 544 545// stolen I mean inspired by https://stackoverflow.com/questions/2970525/converting-a-string-with-spaces-into-camel-case 546function camelize(str: string): string { 547 return str.replace(/(?:^\w|[A-Z]|\b\w|\s+)/g, function (match, index) { 548 if (+match === 0) return ""; // or if (/\s+/.test(match)) for white spaces 549 return index === 0 ? match.toLowerCase() : match.toUpperCase(); 550 }); 551} 552 553function getUserName(user?: User | undefined | null): string { 554 let res = user 555 ? "@" + user.url + "@" + completeEnvironment.instanceUrl 556 : "anonymous"; 557 if (user?.url.startsWith("@")) { 558 res = user.url; 559 } 560 return res; 561} 562 563async function getPostUrlForQuote(post: any): Promise<string> { 564 const isPostFromFedi = !!post.remotePostId; 565 let res = `${completeEnvironment.frontendUrl}/fediverse/post/${post.id}`; 566 if (post.bskyUri && !(await getAllLocalUserIds()).includes(post.userId)) { 567 const parts = post.bskyUri.split("/app.bsky.feed.post/"); 568 const userDid = parts[0].split("at://")[1]; 569 res = `https://bsky.app/profile/${userDid}/post/${parts[1]}`; 570 } 571 if (isPostFromFedi) { 572 res = post.remotePostId; 573 } 574 return res; 575} 576 577export { postToJSONLD, getPostUrlForQuote };