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