social-butterfly
Version:
Incorporate federated social network protocols easily. Used with Hello, world federated blog.
289 lines (254 loc) • 9.35 kB
JavaScript
import { getActivityPubActor, getUserRemoteInfo } from './discover_user';
import { send as activityPubSend } from './activitypub';
import { buildUrl } from './util/url_factory';
import { follow as emailFollow } from './email';
import { mention as emailMention } from './email';
import { fetchJSON } from './util/crawler';
import { nanoid } from 'nanoid';
import { send as salmonSend } from './salmon';
import { sanitizeHTML } from './util/crawler';
import syndicate from './syndicate';
export async function accept(req, contentOwner, userRemote) {
const id = buildUrl({
req,
pathname: '/api/social/activitypub/accept',
searchParams: { id: nanoid(10), resource: req.body },
});
const message = createGenericMessage('Accept', req, id, contentOwner, req.body);
send(req, userRemote, contentOwner, message);
}
export async function like(req, contentOwner, contentRemote, userRemote, isFavorite) {
// TODO(mime): add back unfavorite
const id = buildUrl({
req,
pathname: '/api/social/activitypub/like',
searchParams: { id: nanoid(10), resource: contentRemote.link },
});
const message = createGenericMessage('Like', req, id, contentOwner, {
type: 'Post',
id: contentRemote.link,
displayName: contentRemote.title,
url: contentRemote.link,
});
send(req, userRemote, contentOwner, message);
}
export async function follow(req, contentOwner, userRemote, isFollow) {
// TODO(mime): add back unfollow
const id = buildUrl({
req,
pathname: '/api/social/activitypub/follow',
searchParams: { id: nanoid(10), resource: userRemote.profile_url },
});
const message = createGenericMessage('Follow', req, id, contentOwner, userRemote.profile_url);
send(req, userRemote, contentOwner, message);
}
export async function reply(req, contentOwner, content, userRemote, mentionedRemoteUsers) {
const message = await createArticle(req, content, contentOwner, mentionedRemoteUsers);
send(req, userRemote, contentOwner, message);
}
async function send(req, userRemote, contentOwner, message) {
try {
if (userRemote?.activitypub_inbox_url) {
activityPubSend(req, userRemote, contentOwner, message);
} else if (userRemote?.salmon_url) {
salmonSend(req, userRemote, contentOwner, message);
}
} catch (ex) {
// Not a big deal if this fails.
// TODO(mime): add logging later.
}
}
export function createGenericMessage(type, req, id, localUser, object, opt_follower) {
const actor = buildUrl({ req, pathname: '/api/social/activitypub/actor', searchParams: { resource: localUser.url } });
const json = {
'@context': 'https://www.w3.org/ns/activitystreams',
type,
id,
actor,
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: opt_follower ? [opt_follower.profile_url] : undefined,
object,
};
return json;
}
export async function createArticle(req, localContent, localUser, opt_follower) {
const messageUrl = buildUrl({
req,
pathname: '/api/social/activitypub/message',
searchParams: { resource: localContent.url },
});
const actorUrl = buildUrl({
req,
pathname: '/api/social/activitypub/actor',
searchParams: { resource: localUser.url },
});
const statsImgSrc = buildUrl({ req, pathname: '/api/stats', searchParams: { resource: localContent.url } });
const statsImg = `<img src="${statsImgSrc}" />`;
const absoluteUrlReplacement = buildUrl({ req, pathname: '/resource' });
// TODO(mime): this replacement is nite-lite specific...
const view = localContent.view.replace(/(['"])\/resource/gm, `$1${absoluteUrlReplacement}`) + statsImg;
let inReplyTo = localContent.thread;
if (localContent.thread) {
try {
const activityObject = await fetchJSON(localContent.thread, {
Accept: 'application/activity+json',
});
if (activityObject) {
inReplyTo = activityObject.id;
}
} catch (ex) {}
}
return createGenericMessage(
'Create',
req,
messageUrl,
localUser,
{
id: messageUrl,
url: localContent.url,
type: 'Article',
published: new Date(localContent.createdAt).toISOString(),
updated: new Date(localContent.updatedAt).toISOString(),
attributedTo: actorUrl,
inReplyTo,
title: localContent.title,
content: view,
to: 'https://www.w3.org/ns/activitystreams#Public',
},
opt_follower
);
}
export async function handle(type, options, req, res, activity, user, userRemote) {
switch (type) {
case 'Accept':
// Do nothing.
break;
case 'Create':
await handleCreate(options, req, res, activity, user, userRemote);
break;
case 'Follow':
await handleFollow(options, req, user, userRemote, true);
break;
case 'Like':
await handleLike(options, req, res, activity, userRemote, true);
break;
default:
break;
}
}
export async function findUserRemote(options, json, res, user) {
const actorUrl = json.actor;
let userRemote = await options.getRemoteUserByActor(user.username, actorUrl);
if (!userRemote) {
const actorJSON = await getActivityPubActor(actorUrl);
if (actorJSON.url) {
const userRemoteInfo = await getUserRemoteInfo(actorJSON.url, user.username);
await options.saveRemoteUser(userRemoteInfo);
userRemote = await options.getRemoteUser(user.username, actorJSON.url);
} else {
return res.sendStatus(400);
}
}
return userRemote;
}
async function handleFollow(options, req, user, userRemote, isFollow) {
if (isFollow) {
await options.saveRemoteUser(Object.assign({}, userRemote.dataValues, { follower: true }));
emailFollow(req, user.username, user.email, userRemote.profile_url);
} else {
if (userRemote.following) {
await options.saveRemoteUser(Object.assign({}, userRemote.dataValues, { follower: false }));
} else {
await options.removeRemoteUser(userRemote);
}
}
}
async function handleLike(options, req, res, activity, userRemote, isLike) {
const localContentUrl = activity.object;
const { username, name } = await options.getLocalContent(localContentUrl, req);
if (!name) {
return res.sendStatus(400);
}
const postId = `${userRemote.profile_url},${localContentUrl},favorite`;
const remoteContent = {
from_user: userRemote.profile_url,
local_content_name: name,
post_id: postId,
to_username: username,
type: 'favorite',
username: userRemote.username,
};
if (!isLike) {
await options.removeRemoteContent(remoteContent);
return;
}
const existingFavorite = await options.getRemoteContent(username, postId);
if (existingFavorite) {
return;
}
await options.saveRemoteContent(
Object.assign({}, remoteContent, {
content: '',
createdAt: new Date(),
updatedAt: new Date(),
link: '',
title: '',
view: '',
})
);
}
async function handleCreate(options, req, res, activity, user, userRemote) {
const activityObject = activity.object;
const atomContent = sanitizeHTML(activityObject.content);
const existingContentRemote = await options.getRemoteContent(user.username, activityObject.id);
const contentRemote = {
id: existingContentRemote?.id || undefined,
avatar: userRemote.avatar,
comments_count: parseInt(activityObject.repliesCount),
comments_updated: new Date(activityObject.repliesUpdated || new Date()),
content: '',
createdAt: new Date(activityObject.published || new Date()),
from_user: userRemote.profile_url,
from_user_remote_id: userRemote.id,
creator: userRemote.name,
link: activityObject.id,
post_id: activityObject.id,
title: activityObject.title,
to_username: user.username,
updatedAt: new Date(activityObject.updated || new Date()),
username: userRemote.username,
view: atomContent,
};
if (activityObject.inReplyTo) {
handleComment(options, req, res, contentRemote, activityObject.inReplyTo);
} else {
handlePost(options, req, res, contentRemote, user, activityObject);
}
//const repliesCount = activityObject.repliesCount;
// if (repliesCount) {
// // TODO(mime): these used to be known as 'remote-comment' types.
// // need to refactor this.
// const comments = await retrieveFeed(activityObject.repliesLink);
// await parseFeedAndInsertIntoDb(salmon, userRemote, comments);
// }
}
async function handlePost(options, req, res, contentRemote, user, activityObject) {
// TODO(mime): need to unify this with activitypub.js, currently salmon specific...
const wasUserMentioned = activityObject.mentioned || activityObject.attention;
contentRemote.type = 'post';
await options.saveRemoteContent(contentRemote);
if (wasUserMentioned) {
emailMention(req, 'Remote User', undefined /* fromEmail */, user.email, contentRemote.link);
}
}
async function handleComment(options, req, res, contentRemote, inReplyTo) {
const content = await options.getLocalContent(inReplyTo, req);
if (!content) {
return res.sendStatus(404);
}
contentRemote.type = 'comment';
contentRemote.local_content_name = content.name;
await options.saveRemoteContent(contentRemote);
const contentOwner = await options.getLocalUser(inReplyTo, req);
await syndicate(req, contentOwner, content, contentRemote, true /* isComment */);
}