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 { 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 };