parent
							
								
									037837b551
								
							
						
					
					
						commit
						0e4a111f81
					
				
					 1714 changed files with 20803 additions and 11751 deletions
				
			
		
							
								
								
									
										104
									
								
								packages/backend/src/remote/activitypub/ap-request.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								packages/backend/src/remote/activitypub/ap-request.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,104 @@
 | 
			
		|||
import * as crypto from 'crypto';
 | 
			
		||||
import { URL } from 'url';
 | 
			
		||||
 | 
			
		||||
type Request = {
 | 
			
		||||
	url: string;
 | 
			
		||||
	method: string;
 | 
			
		||||
	headers: Record<string, string>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type PrivateKey = {
 | 
			
		||||
	privateKeyPem: string;
 | 
			
		||||
	keyId: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }) {
 | 
			
		||||
	const u = new URL(args.url);
 | 
			
		||||
	const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`;
 | 
			
		||||
 | 
			
		||||
	const request: Request = {
 | 
			
		||||
		url: u.href,
 | 
			
		||||
		method: 'POST',
 | 
			
		||||
		headers:  objectAssignWithLcKey({
 | 
			
		||||
			'Date': new Date().toUTCString(),
 | 
			
		||||
			'Host': u.hostname,
 | 
			
		||||
			'Content-Type': 'application/activity+json',
 | 
			
		||||
			'Digest': digestHeader,
 | 
			
		||||
		}, args.additionalHeaders),
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const result = signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		request,
 | 
			
		||||
		signingString: result.signingString,
 | 
			
		||||
		signature: result.signature,
 | 
			
		||||
		signatureHeader: result.signatureHeader,
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }) {
 | 
			
		||||
	const u = new URL(args.url);
 | 
			
		||||
 | 
			
		||||
	const request: Request = {
 | 
			
		||||
		url: u.href,
 | 
			
		||||
		method: 'GET',
 | 
			
		||||
		headers:  objectAssignWithLcKey({
 | 
			
		||||
			'Accept': 'application/activity+json, application/ld+json',
 | 
			
		||||
			'Date': new Date().toUTCString(),
 | 
			
		||||
			'Host': new URL(args.url).hostname,
 | 
			
		||||
		}, args.additionalHeaders),
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const result = signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		request,
 | 
			
		||||
		signingString: result.signingString,
 | 
			
		||||
		signature: result.signature,
 | 
			
		||||
		signatureHeader: result.signatureHeader,
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]) {
 | 
			
		||||
	const signingString = genSigningString(request, includeHeaders);
 | 
			
		||||
	const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64');
 | 
			
		||||
	const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`;
 | 
			
		||||
 | 
			
		||||
	request.headers = objectAssignWithLcKey(request.headers, {
 | 
			
		||||
		Signature: signatureHeader
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		request,
 | 
			
		||||
		signingString,
 | 
			
		||||
		signature,
 | 
			
		||||
		signatureHeader,
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function genSigningString(request: Request, includeHeaders: string[]) {
 | 
			
		||||
	request.headers = lcObjectKey(request.headers);
 | 
			
		||||
 | 
			
		||||
	const results: string[] = [];
 | 
			
		||||
 | 
			
		||||
	for (const key of includeHeaders.map(x => x.toLowerCase())) {
 | 
			
		||||
		if (key === '(request-target)') {
 | 
			
		||||
			results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`);
 | 
			
		||||
		} else {
 | 
			
		||||
			results.push(`${key}: ${request.headers[key]}`);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return results.join('\n');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function lcObjectKey(src: Record<string, string>) {
 | 
			
		||||
	const dst: Record<string, string> = {};
 | 
			
		||||
	for (const key of Object.keys(src).filter(x => x != '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key];
 | 
			
		||||
	return dst;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>) {
 | 
			
		||||
	return Object.assign(lcObjectKey(a), lcObjectKey(b));
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										92
									
								
								packages/backend/src/remote/activitypub/audience.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								packages/backend/src/remote/activitypub/audience.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,92 @@
 | 
			
		|||
import { ApObject, getApIds } from './type';
 | 
			
		||||
import Resolver from './resolver';
 | 
			
		||||
import { resolvePerson } from './models/person';
 | 
			
		||||
import { unique, concat } from '@/prelude/array';
 | 
			
		||||
import * as promiseLimit from 'promise-limit';
 | 
			
		||||
import { User, IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
type Visibility = 'public' | 'home' | 'followers' | 'specified';
 | 
			
		||||
 | 
			
		||||
type AudienceInfo = {
 | 
			
		||||
	visibility: Visibility,
 | 
			
		||||
	mentionedUsers: User[],
 | 
			
		||||
	visibleUsers: User[],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export async function parseAudience(actor: IRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise<AudienceInfo> {
 | 
			
		||||
	const toGroups = groupingAudience(getApIds(to), actor);
 | 
			
		||||
	const ccGroups = groupingAudience(getApIds(cc), actor);
 | 
			
		||||
 | 
			
		||||
	const others = unique(concat([toGroups.other, ccGroups.other]));
 | 
			
		||||
 | 
			
		||||
	const limit = promiseLimit<User | null>(2);
 | 
			
		||||
	const mentionedUsers = (await Promise.all(
 | 
			
		||||
		others.map(id => limit(() => resolvePerson(id, resolver).catch(() => null)))
 | 
			
		||||
	)).filter((x): x is User => x != null);
 | 
			
		||||
 | 
			
		||||
	if (toGroups.public.length > 0) {
 | 
			
		||||
		return {
 | 
			
		||||
			visibility: 'public',
 | 
			
		||||
			mentionedUsers,
 | 
			
		||||
			visibleUsers: []
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (ccGroups.public.length > 0) {
 | 
			
		||||
		return {
 | 
			
		||||
			visibility: 'home',
 | 
			
		||||
			mentionedUsers,
 | 
			
		||||
			visibleUsers: []
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (toGroups.followers.length > 0) {
 | 
			
		||||
		return {
 | 
			
		||||
			visibility: 'followers',
 | 
			
		||||
			mentionedUsers,
 | 
			
		||||
			visibleUsers: []
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		visibility: 'specified',
 | 
			
		||||
		mentionedUsers,
 | 
			
		||||
		visibleUsers: mentionedUsers
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function groupingAudience(ids: string[], actor: IRemoteUser) {
 | 
			
		||||
	const groups = {
 | 
			
		||||
		public: [] as string[],
 | 
			
		||||
		followers: [] as string[],
 | 
			
		||||
		other: [] as string[],
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	for (const id of ids) {
 | 
			
		||||
		if (isPublic(id)) {
 | 
			
		||||
			groups.public.push(id);
 | 
			
		||||
		} else if (isFollowers(id, actor)) {
 | 
			
		||||
			groups.followers.push(id);
 | 
			
		||||
		} else {
 | 
			
		||||
			groups.other.push(id);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	groups.other = unique(groups.other);
 | 
			
		||||
 | 
			
		||||
	return groups;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isPublic(id: string) {
 | 
			
		||||
	return [
 | 
			
		||||
		'https://www.w3.org/ns/activitystreams#Public',
 | 
			
		||||
		'as#Public',
 | 
			
		||||
		'Public',
 | 
			
		||||
	].includes(id);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isFollowers(id: string, actor: IRemoteUser) {
 | 
			
		||||
	return (
 | 
			
		||||
		id === (actor.followersUri || `${actor.uri}/followers`)
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										140
									
								
								packages/backend/src/remote/activitypub/db-resolver.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								packages/backend/src/remote/activitypub/db-resolver.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,140 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { Note } from '@/models/entities/note';
 | 
			
		||||
import { User, IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { UserPublickey } from '@/models/entities/user-publickey';
 | 
			
		||||
import { MessagingMessage } from '@/models/entities/messaging-message';
 | 
			
		||||
import { Notes, Users, UserPublickeys, MessagingMessages } from '@/models/index';
 | 
			
		||||
import { IObject, getApId } from './type';
 | 
			
		||||
import { resolvePerson } from './models/person';
 | 
			
		||||
import escapeRegexp = require('escape-regexp');
 | 
			
		||||
 | 
			
		||||
export default class DbResolver {
 | 
			
		||||
	constructor() {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * AP Note => Misskey Note in DB
 | 
			
		||||
	 */
 | 
			
		||||
	public async getNoteFromApId(value: string | IObject): Promise<Note | null> {
 | 
			
		||||
		const parsed = this.parseUri(value);
 | 
			
		||||
 | 
			
		||||
		if (parsed.id) {
 | 
			
		||||
			return (await Notes.findOne({
 | 
			
		||||
				id: parsed.id
 | 
			
		||||
			})) || null;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (parsed.uri) {
 | 
			
		||||
			return (await Notes.findOne({
 | 
			
		||||
				uri: parsed.uri
 | 
			
		||||
			})) || null;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async getMessageFromApId(value: string | IObject): Promise<MessagingMessage | null> {
 | 
			
		||||
		const parsed = this.parseUri(value);
 | 
			
		||||
 | 
			
		||||
		if (parsed.id) {
 | 
			
		||||
			return (await MessagingMessages.findOne({
 | 
			
		||||
				id: parsed.id
 | 
			
		||||
			})) || null;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (parsed.uri) {
 | 
			
		||||
			return (await MessagingMessages.findOne({
 | 
			
		||||
				uri: parsed.uri
 | 
			
		||||
			})) || null;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * AP Person => Misskey User in DB
 | 
			
		||||
	 */
 | 
			
		||||
	public async getUserFromApId(value: string | IObject): Promise<User | null> {
 | 
			
		||||
		const parsed = this.parseUri(value);
 | 
			
		||||
 | 
			
		||||
		if (parsed.id) {
 | 
			
		||||
			return (await Users.findOne({
 | 
			
		||||
				id: parsed.id
 | 
			
		||||
			})) || null;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (parsed.uri) {
 | 
			
		||||
			return (await Users.findOne({
 | 
			
		||||
				uri: parsed.uri
 | 
			
		||||
			})) || null;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * AP KeyId => Misskey User and Key
 | 
			
		||||
	 */
 | 
			
		||||
	public async getAuthUserFromKeyId(keyId: string): Promise<AuthUser | null> {
 | 
			
		||||
		const key = await UserPublickeys.findOne({
 | 
			
		||||
			keyId
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (key == null) return null;
 | 
			
		||||
 | 
			
		||||
		const user = await Users.findOne(key.userId) as IRemoteUser;
 | 
			
		||||
 | 
			
		||||
		return {
 | 
			
		||||
			user,
 | 
			
		||||
			key
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * AP Actor id => Misskey User and Key
 | 
			
		||||
	 */
 | 
			
		||||
	public async getAuthUserFromApId(uri: string): Promise<AuthUser | null> {
 | 
			
		||||
		const user = await resolvePerson(uri) as IRemoteUser;
 | 
			
		||||
 | 
			
		||||
		if (user == null) return null;
 | 
			
		||||
 | 
			
		||||
		const key = await UserPublickeys.findOne(user.id);
 | 
			
		||||
 | 
			
		||||
		return {
 | 
			
		||||
			user,
 | 
			
		||||
			key
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public parseUri(value: string | IObject): UriParseResult {
 | 
			
		||||
		const uri = getApId(value);
 | 
			
		||||
 | 
			
		||||
		const localRegex = new RegExp('^' + escapeRegexp(config.url) + '/' + '(\\w+)' + '/' + '(\\w+)');
 | 
			
		||||
		const matchLocal = uri.match(localRegex);
 | 
			
		||||
 | 
			
		||||
		if (matchLocal) {
 | 
			
		||||
			return {
 | 
			
		||||
				type: matchLocal[1],
 | 
			
		||||
				id: matchLocal[2]
 | 
			
		||||
			};
 | 
			
		||||
		} else {
 | 
			
		||||
			return {
 | 
			
		||||
				uri
 | 
			
		||||
			};
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type AuthUser = {
 | 
			
		||||
	user: IRemoteUser;
 | 
			
		||||
	key?: UserPublickey;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type UriParseResult = {
 | 
			
		||||
	/** id in DB (local object only) */
 | 
			
		||||
	id?: string;
 | 
			
		||||
	/** uri in DB (remote object only) */
 | 
			
		||||
	uri?: string;
 | 
			
		||||
	/** hint of type (local object only, ex: notes, users) */
 | 
			
		||||
	type?: string
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										131
									
								
								packages/backend/src/remote/activitypub/deliver-manager.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								packages/backend/src/remote/activitypub/deliver-manager.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,131 @@
 | 
			
		|||
import { Users, Followings } from '@/models/index';
 | 
			
		||||
import { ILocalUser, IRemoteUser, User } from '@/models/entities/user';
 | 
			
		||||
import { deliver } from '@/queue/index';
 | 
			
		||||
 | 
			
		||||
//#region types
 | 
			
		||||
interface IRecipe {
 | 
			
		||||
	type: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface IFollowersRecipe extends IRecipe {
 | 
			
		||||
	type: 'Followers';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface IDirectRecipe extends IRecipe {
 | 
			
		||||
	type: 'Direct';
 | 
			
		||||
	to: IRemoteUser;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const isFollowers = (recipe: any): recipe is IFollowersRecipe =>
 | 
			
		||||
	recipe.type === 'Followers';
 | 
			
		||||
 | 
			
		||||
const isDirect = (recipe: any): recipe is IDirectRecipe =>
 | 
			
		||||
	recipe.type === 'Direct';
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
export default class DeliverManager {
 | 
			
		||||
	private actor: { id: User['id']; host: null; };
 | 
			
		||||
	private activity: any;
 | 
			
		||||
	private recipes: IRecipe[] = [];
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Constructor
 | 
			
		||||
	 * @param actor Actor
 | 
			
		||||
	 * @param activity Activity to deliver
 | 
			
		||||
	 */
 | 
			
		||||
	constructor(actor: { id: User['id']; host: null; }, activity: any) {
 | 
			
		||||
		this.actor = actor;
 | 
			
		||||
		this.activity = activity;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Add recipe for followers deliver
 | 
			
		||||
	 */
 | 
			
		||||
	public addFollowersRecipe() {
 | 
			
		||||
		const deliver = {
 | 
			
		||||
			type: 'Followers'
 | 
			
		||||
		} as IFollowersRecipe;
 | 
			
		||||
 | 
			
		||||
		this.addRecipe(deliver);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Add recipe for direct deliver
 | 
			
		||||
	 * @param to To
 | 
			
		||||
	 */
 | 
			
		||||
	public addDirectRecipe(to: IRemoteUser) {
 | 
			
		||||
		const recipe = {
 | 
			
		||||
			type: 'Direct',
 | 
			
		||||
			to
 | 
			
		||||
		} as IDirectRecipe;
 | 
			
		||||
 | 
			
		||||
		this.addRecipe(recipe);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Add recipe
 | 
			
		||||
	 * @param recipe Recipe
 | 
			
		||||
	 */
 | 
			
		||||
	public addRecipe(recipe: IRecipe) {
 | 
			
		||||
		this.recipes.push(recipe);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Execute delivers
 | 
			
		||||
	 */
 | 
			
		||||
	public async execute() {
 | 
			
		||||
		if (!Users.isLocalUser(this.actor)) return;
 | 
			
		||||
 | 
			
		||||
		const inboxes = new Set<string>();
 | 
			
		||||
 | 
			
		||||
		// build inbox list
 | 
			
		||||
		for (const recipe of this.recipes) {
 | 
			
		||||
			if (isFollowers(recipe)) {
 | 
			
		||||
				// followers deliver
 | 
			
		||||
				const followers = await Followings.find({
 | 
			
		||||
					followeeId: this.actor.id
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				for (const following of followers) {
 | 
			
		||||
					if (Followings.isRemoteFollower(following)) {
 | 
			
		||||
						const inbox = following.followerSharedInbox || following.followerInbox;
 | 
			
		||||
						inboxes.add(inbox);
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			} else if (isDirect(recipe)) {
 | 
			
		||||
				// direct deliver
 | 
			
		||||
				const inbox = recipe.to.inbox;
 | 
			
		||||
				if (inbox) inboxes.add(inbox);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// deliver
 | 
			
		||||
		for (const inbox of inboxes) {
 | 
			
		||||
			deliver(this.actor, this.activity, inbox);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//#region Utilities
 | 
			
		||||
/**
 | 
			
		||||
 * Deliver activity to followers
 | 
			
		||||
 * @param activity Activity
 | 
			
		||||
 * @param from Followee
 | 
			
		||||
 */
 | 
			
		||||
export async function deliverToFollowers(actor: ILocalUser, activity: any) {
 | 
			
		||||
	const manager = new DeliverManager(actor, activity);
 | 
			
		||||
	manager.addFollowersRecipe();
 | 
			
		||||
	await manager.execute();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Deliver activity to user
 | 
			
		||||
 * @param activity Activity
 | 
			
		||||
 * @param to Target user
 | 
			
		||||
 */
 | 
			
		||||
export async function deliverToUser(actor: ILocalUser, activity: any, to: IRemoteUser) {
 | 
			
		||||
	const manager = new DeliverManager(actor, activity);
 | 
			
		||||
	manager.addDirectRecipe(to);
 | 
			
		||||
	await manager.execute();
 | 
			
		||||
}
 | 
			
		||||
//#endregion
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import accept from '@/services/following/requests/accept';
 | 
			
		||||
import { IFollow } from '../../type';
 | 
			
		||||
import DbResolver from '../../db-resolver';
 | 
			
		||||
import { relayAccepted } from '@/services/relay';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => {
 | 
			
		||||
	// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
 | 
			
		||||
 | 
			
		||||
	const dbResolver = new DbResolver();
 | 
			
		||||
	const follower = await dbResolver.getUserFromApId(activity.actor);
 | 
			
		||||
 | 
			
		||||
	if (follower == null) {
 | 
			
		||||
		return `skip: follower not found`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (follower.host != null) {
 | 
			
		||||
		return `skip: follower is not a local user`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// relay
 | 
			
		||||
	const match = activity.id?.match(/follow-relay\/(\w+)/);
 | 
			
		||||
	if (match) {
 | 
			
		||||
		return await relayAccepted(match[1]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	await accept(actor, follower);
 | 
			
		||||
	return `ok`;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
import Resolver from '../../resolver';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import acceptFollow from './follow';
 | 
			
		||||
import { IAccept, isFollow, getApType } from '../../type';
 | 
			
		||||
import { apLogger } from '../../logger';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IAccept): Promise<string> => {
 | 
			
		||||
	const uri = activity.id || activity;
 | 
			
		||||
 | 
			
		||||
	logger.info(`Accept: ${uri}`);
 | 
			
		||||
 | 
			
		||||
	const resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const object = await resolver.resolve(activity.object).catch(e => {
 | 
			
		||||
		logger.error(`Resolution failed: ${e}`);
 | 
			
		||||
		throw e;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (isFollow(object)) return await acceptFollow(actor, object);
 | 
			
		||||
 | 
			
		||||
	return `skip: Unknown Accept type: ${getApType(object)}`;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										23
									
								
								packages/backend/src/remote/activitypub/kernel/add/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/backend/src/remote/activitypub/kernel/add/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { IAdd } from '../../type';
 | 
			
		||||
import { resolveNote } from '../../models/note';
 | 
			
		||||
import { addPinned } from '@/services/i/pin';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IAdd): Promise<void> => {
 | 
			
		||||
	if ('actor' in activity && actor.uri !== activity.actor) {
 | 
			
		||||
		throw new Error('invalid actor');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (activity.target == null) {
 | 
			
		||||
		throw new Error('target is null');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (activity.target === actor.featured) {
 | 
			
		||||
		const note = await resolveNote(activity.object);
 | 
			
		||||
		if (note == null) throw new Error('note not found');
 | 
			
		||||
		await addPinned(actor, note.id);
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	throw new Error(`unknown target: ${activity.target}`);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
import Resolver from '../../resolver';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import announceNote from './note';
 | 
			
		||||
import { IAnnounce, getApId } from '../../type';
 | 
			
		||||
import { apLogger } from '../../logger';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IAnnounce): Promise<void> => {
 | 
			
		||||
	const uri = getApId(activity);
 | 
			
		||||
 | 
			
		||||
	logger.info(`Announce: ${uri}`);
 | 
			
		||||
 | 
			
		||||
	const resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const targetUri = getApId(activity.object);
 | 
			
		||||
 | 
			
		||||
	announceNote(resolver, actor, activity, targetUri);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,67 @@
 | 
			
		|||
import Resolver from '../../resolver';
 | 
			
		||||
import post from '@/services/note/create';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { IAnnounce, getApId } from '../../type';
 | 
			
		||||
import { fetchNote, resolveNote } from '../../models/note';
 | 
			
		||||
import { apLogger } from '../../logger';
 | 
			
		||||
import { extractDbHost } from '@/misc/convert-host';
 | 
			
		||||
import { fetchMeta } from '@/misc/fetch-meta';
 | 
			
		||||
import { getApLock } from '@/misc/app-lock';
 | 
			
		||||
import { parseAudience } from '../../audience';
 | 
			
		||||
import { StatusError } from '@/misc/fetch';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * アナウンスアクティビティを捌きます
 | 
			
		||||
 */
 | 
			
		||||
export default async function(resolver: Resolver, actor: IRemoteUser, activity: IAnnounce, targetUri: string): Promise<void> {
 | 
			
		||||
	const uri = getApId(activity);
 | 
			
		||||
 | 
			
		||||
	// アナウンサーが凍結されていたらスキップ
 | 
			
		||||
	if (actor.isSuspended) {
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// アナウンス先をブロックしてたら中断
 | 
			
		||||
	const meta = await fetchMeta();
 | 
			
		||||
	if (meta.blockedHosts.includes(extractDbHost(uri))) return;
 | 
			
		||||
 | 
			
		||||
	const unlock = await getApLock(uri);
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		// 既に同じURIを持つものが登録されていないかチェック
 | 
			
		||||
		const exist = await fetchNote(uri);
 | 
			
		||||
		if (exist) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Announce対象をresolve
 | 
			
		||||
		let renote;
 | 
			
		||||
		try {
 | 
			
		||||
			renote = await resolveNote(targetUri);
 | 
			
		||||
		} catch (e) {
 | 
			
		||||
			// 対象が4xxならスキップ
 | 
			
		||||
			if (e instanceof StatusError && e.isClientError) {
 | 
			
		||||
				logger.warn(`Ignored announce target ${targetUri} - ${e.statusCode}`);
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
			logger.warn(`Error in announce target ${targetUri} - ${e.statusCode || e}`);
 | 
			
		||||
			throw e;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		logger.info(`Creating the (Re)Note: ${uri}`);
 | 
			
		||||
 | 
			
		||||
		const activityAudience = await parseAudience(actor, activity.to, activity.cc);
 | 
			
		||||
 | 
			
		||||
		await post(actor, {
 | 
			
		||||
			createdAt: activity.published ? new Date(activity.published) : null,
 | 
			
		||||
			renote,
 | 
			
		||||
			visibility: activityAudience.visibility,
 | 
			
		||||
			visibleUsers: activityAudience.visibleUsers,
 | 
			
		||||
			uri
 | 
			
		||||
		});
 | 
			
		||||
	} finally {
 | 
			
		||||
		unlock();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
import { IBlock } from '../../type';
 | 
			
		||||
import block from '@/services/blocking/create';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import DbResolver from '../../db-resolver';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IBlock): Promise<string> => {
 | 
			
		||||
	// ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず
 | 
			
		||||
 | 
			
		||||
	const dbResolver = new DbResolver();
 | 
			
		||||
	const blockee = await dbResolver.getUserFromApId(activity.object);
 | 
			
		||||
 | 
			
		||||
	if (blockee == null) {
 | 
			
		||||
		return `skip: blockee not found`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (blockee.host != null) {
 | 
			
		||||
		return `skip: ブロックしようとしているユーザーはローカルユーザーではありません`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	await block(actor, blockee);
 | 
			
		||||
	return `ok`;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,43 @@
 | 
			
		|||
import Resolver from '../../resolver';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import createNote from './note';
 | 
			
		||||
import { ICreate, getApId, isPost, getApType } from '../../type';
 | 
			
		||||
import { apLogger } from '../../logger';
 | 
			
		||||
import { toArray, concat, unique } from '@/prelude/array';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: ICreate): Promise<void> => {
 | 
			
		||||
	const uri = getApId(activity);
 | 
			
		||||
 | 
			
		||||
	logger.info(`Create: ${uri}`);
 | 
			
		||||
 | 
			
		||||
	// copy audiences between activity <=> object.
 | 
			
		||||
	if (typeof activity.object === 'object') {
 | 
			
		||||
		const to = unique(concat([toArray(activity.to), toArray(activity.object.to)]));
 | 
			
		||||
		const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)]));
 | 
			
		||||
 | 
			
		||||
		activity.to = to;
 | 
			
		||||
		activity.cc = cc;
 | 
			
		||||
		activity.object.to = to;
 | 
			
		||||
		activity.object.cc = cc;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// If there is no attributedTo, use Activity actor.
 | 
			
		||||
	if (typeof activity.object === 'object' && !activity.object.attributedTo) {
 | 
			
		||||
		activity.object.attributedTo = activity.actor;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const object = await resolver.resolve(activity.object).catch(e => {
 | 
			
		||||
		logger.error(`Resolution failed: ${e}`);
 | 
			
		||||
		throw e;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (isPost(object)) {
 | 
			
		||||
		createNote(resolver, actor, object, false, activity);
 | 
			
		||||
	} else {
 | 
			
		||||
		logger.warn(`Unknown type: ${getApType(object)}`);
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,44 @@
 | 
			
		|||
import Resolver from '../../resolver';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { createNote, fetchNote } from '../../models/note';
 | 
			
		||||
import { getApId, IObject, ICreate } from '../../type';
 | 
			
		||||
import { getApLock } from '@/misc/app-lock';
 | 
			
		||||
import { extractDbHost } from '@/misc/convert-host';
 | 
			
		||||
import { StatusError } from '@/misc/fetch';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 投稿作成アクティビティを捌きます
 | 
			
		||||
 */
 | 
			
		||||
export default async function(resolver: Resolver, actor: IRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise<string> {
 | 
			
		||||
	const uri = getApId(note);
 | 
			
		||||
 | 
			
		||||
	if (typeof note === 'object') {
 | 
			
		||||
		if (actor.uri !== note.attributedTo) {
 | 
			
		||||
			return `skip: actor.uri !== note.attributedTo`;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (typeof note.id === 'string') {
 | 
			
		||||
			if (extractDbHost(actor.uri) !== extractDbHost(note.id)) {
 | 
			
		||||
				return `skip: host in actor.uri !== note.id`;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const unlock = await getApLock(uri);
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		const exist = await fetchNote(note);
 | 
			
		||||
		if (exist) return 'skip: note exists';
 | 
			
		||||
 | 
			
		||||
		await createNote(note, resolver, silent);
 | 
			
		||||
		return 'ok';
 | 
			
		||||
	} catch (e) {
 | 
			
		||||
		if (e instanceof StatusError && e.isClientError) {
 | 
			
		||||
			return `skip ${e.statusCode}`;
 | 
			
		||||
		} else {
 | 
			
		||||
			throw e;
 | 
			
		||||
		}
 | 
			
		||||
	} finally {
 | 
			
		||||
		unlock();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
import { apLogger } from '../../logger';
 | 
			
		||||
import { createDeleteAccountJob } from '@/queue';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { Users } from '@/models/index';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
export async function deleteActor(actor: IRemoteUser, uri: string): Promise<string> {
 | 
			
		||||
	logger.info(`Deleting the Actor: ${uri}`);
 | 
			
		||||
 | 
			
		||||
	if (actor.uri !== uri) {
 | 
			
		||||
		return `skip: delete actor ${actor.uri} !== ${uri}`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (actor.isDeleted) {
 | 
			
		||||
		logger.info(`skip: already deleted`);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const job = await createDeleteAccountJob(actor);
 | 
			
		||||
 | 
			
		||||
	await Users.update(actor.id, {
 | 
			
		||||
		isDeleted: true,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	return `ok: queued ${job.name} ${job.id}`;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,49 @@
 | 
			
		|||
import deleteNote from './note';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { IDelete, getApId, isTombstone, IObject, validPost, validActor } from '../../type';
 | 
			
		||||
import { toSingle } from '@/prelude/array';
 | 
			
		||||
import { deleteActor } from './actor';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 削除アクティビティを捌きます
 | 
			
		||||
 */
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IDelete): Promise<string> => {
 | 
			
		||||
	if ('actor' in activity && actor.uri !== activity.actor) {
 | 
			
		||||
		throw new Error('invalid actor');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 削除対象objectのtype
 | 
			
		||||
	let formarType: string | undefined;
 | 
			
		||||
 | 
			
		||||
	if (typeof activity.object === 'string') {
 | 
			
		||||
		// typeが不明だけど、どうせ消えてるのでremote resolveしない
 | 
			
		||||
		formarType = undefined;
 | 
			
		||||
	} else {
 | 
			
		||||
		const object = activity.object as IObject;
 | 
			
		||||
		if (isTombstone(object)) {
 | 
			
		||||
			formarType = toSingle(object.formerType);
 | 
			
		||||
		} else {
 | 
			
		||||
			formarType = toSingle(object.type);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const uri = getApId(activity.object);
 | 
			
		||||
 | 
			
		||||
	// type不明でもactorとobjectが同じならばそれはPersonに違いない
 | 
			
		||||
	if (!formarType && actor.uri === uri) {
 | 
			
		||||
		formarType = 'Person';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// それでもなかったらおそらくNote
 | 
			
		||||
	if (!formarType) {
 | 
			
		||||
		formarType = 'Note';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (validPost.includes(formarType)) {
 | 
			
		||||
		return await deleteNote(actor, uri);
 | 
			
		||||
	} else if (validActor.includes(formarType)) {
 | 
			
		||||
		return await deleteActor(actor, uri);
 | 
			
		||||
	} else {
 | 
			
		||||
		return `Unknown type ${formarType}`;
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import deleteNode from '@/services/note/delete';
 | 
			
		||||
import { apLogger } from '../../logger';
 | 
			
		||||
import DbResolver from '../../db-resolver';
 | 
			
		||||
import { getApLock } from '@/misc/app-lock';
 | 
			
		||||
import { deleteMessage } from '@/services/messages/delete';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
export default async function(actor: IRemoteUser, uri: string): Promise<string> {
 | 
			
		||||
	logger.info(`Deleting the Note: ${uri}`);
 | 
			
		||||
 | 
			
		||||
	const unlock = await getApLock(uri);
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		const dbResolver = new DbResolver();
 | 
			
		||||
		const note = await dbResolver.getNoteFromApId(uri);
 | 
			
		||||
 | 
			
		||||
		if (note == null) {
 | 
			
		||||
			const message = await dbResolver.getMessageFromApId(uri);
 | 
			
		||||
			if (message == null) return 'message not found';
 | 
			
		||||
 | 
			
		||||
			if (message.userId !== actor.id) {
 | 
			
		||||
				return '投稿を削除しようとしているユーザーは投稿の作成者ではありません';
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			await deleteMessage(message);
 | 
			
		||||
 | 
			
		||||
			return 'ok: message deleted';
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (note.userId !== actor.id) {
 | 
			
		||||
			return '投稿を削除しようとしているユーザーは投稿の作成者ではありません';
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		await deleteNode(actor, note);
 | 
			
		||||
		return 'ok: note deleted';
 | 
			
		||||
	} finally {
 | 
			
		||||
		unlock();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								packages/backend/src/remote/activitypub/kernel/flag/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								packages/backend/src/remote/activitypub/kernel/flag/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import config from '@/config/index';
 | 
			
		||||
import { IFlag, getApIds } from '../../type';
 | 
			
		||||
import { AbuseUserReports, Users } from '@/models/index';
 | 
			
		||||
import { In } from 'typeorm';
 | 
			
		||||
import { genId } from '@/misc/gen-id';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IFlag): Promise<string> => {
 | 
			
		||||
	// objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので
 | 
			
		||||
	// 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する
 | 
			
		||||
	const uris = getApIds(activity.object);
 | 
			
		||||
 | 
			
		||||
	const userIds = uris.filter(uri => uri.startsWith(config.url + '/users/')).map(uri => uri.split('/').pop());
 | 
			
		||||
	const users = await Users.find({
 | 
			
		||||
		id: In(userIds)
 | 
			
		||||
	});
 | 
			
		||||
	if (users.length < 1) return `skip`;
 | 
			
		||||
 | 
			
		||||
	await AbuseUserReports.insert({
 | 
			
		||||
		id: genId(),
 | 
			
		||||
		createdAt: new Date(),
 | 
			
		||||
		targetUserId: users[0].id,
 | 
			
		||||
		targetUserHost: users[0].host,
 | 
			
		||||
		reporterId: actor.id,
 | 
			
		||||
		reporterHost: actor.host,
 | 
			
		||||
		comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	return `ok`;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										20
									
								
								packages/backend/src/remote/activitypub/kernel/follow.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								packages/backend/src/remote/activitypub/kernel/follow.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import follow from '@/services/following/create';
 | 
			
		||||
import { IFollow } from '../type';
 | 
			
		||||
import DbResolver from '../db-resolver';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => {
 | 
			
		||||
	const dbResolver = new DbResolver();
 | 
			
		||||
	const followee = await dbResolver.getUserFromApId(activity.object);
 | 
			
		||||
 | 
			
		||||
	if (followee == null) {
 | 
			
		||||
		return `skip: followee not found`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (followee.host != null) {
 | 
			
		||||
		return `skip: フォローしようとしているユーザーはローカルユーザーではありません`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	await follow(actor, followee, activity.id);
 | 
			
		||||
	return `ok`;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										71
									
								
								packages/backend/src/remote/activitypub/kernel/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								packages/backend/src/remote/activitypub/kernel/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag } from '../type';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import create from './create/index';
 | 
			
		||||
import performDeleteActivity from './delete/index';
 | 
			
		||||
import performUpdateActivity from './update/index';
 | 
			
		||||
import { performReadActivity } from './read';
 | 
			
		||||
import follow from './follow';
 | 
			
		||||
import undo from './undo/index';
 | 
			
		||||
import like from './like';
 | 
			
		||||
import announce from './announce/index';
 | 
			
		||||
import accept from './accept/index';
 | 
			
		||||
import reject from './reject/index';
 | 
			
		||||
import add from './add/index';
 | 
			
		||||
import remove from './remove/index';
 | 
			
		||||
import block from './block/index';
 | 
			
		||||
import flag from './flag/index';
 | 
			
		||||
import { apLogger } from '../logger';
 | 
			
		||||
import Resolver from '../resolver';
 | 
			
		||||
import { toArray } from '@/prelude/array';
 | 
			
		||||
 | 
			
		||||
export async function performActivity(actor: IRemoteUser, activity: IObject) {
 | 
			
		||||
	if (isCollectionOrOrderedCollection(activity)) {
 | 
			
		||||
		const resolver = new Resolver();
 | 
			
		||||
		for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
 | 
			
		||||
			const act = await resolver.resolve(item);
 | 
			
		||||
			try {
 | 
			
		||||
				await performOneActivity(actor, act);
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				apLogger.error(e);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		await performOneActivity(actor, activity);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function performOneActivity(actor: IRemoteUser, activity: IObject): Promise<void> {
 | 
			
		||||
	if (actor.isSuspended) return;
 | 
			
		||||
 | 
			
		||||
	if (isCreate(activity)) {
 | 
			
		||||
		await create(actor, activity);
 | 
			
		||||
	} else if (isDelete(activity)) {
 | 
			
		||||
		await performDeleteActivity(actor, activity);
 | 
			
		||||
	} else if (isUpdate(activity)) {
 | 
			
		||||
		await performUpdateActivity(actor, activity);
 | 
			
		||||
	} else if (isRead(activity)) {
 | 
			
		||||
		await performReadActivity(actor, activity);
 | 
			
		||||
	} else if (isFollow(activity)) {
 | 
			
		||||
		await follow(actor, activity);
 | 
			
		||||
	} else if (isAccept(activity)) {
 | 
			
		||||
		await accept(actor, activity);
 | 
			
		||||
	} else if (isReject(activity)) {
 | 
			
		||||
		await reject(actor, activity);
 | 
			
		||||
	} else if (isAdd(activity)) {
 | 
			
		||||
		await add(actor, activity).catch(err => apLogger.error(err));
 | 
			
		||||
	} else if (isRemove(activity)) {
 | 
			
		||||
		await remove(actor, activity).catch(err => apLogger.error(err));
 | 
			
		||||
	} else if (isAnnounce(activity)) {
 | 
			
		||||
		await announce(actor, activity);
 | 
			
		||||
	} else if (isLike(activity)) {
 | 
			
		||||
		await like(actor, activity);
 | 
			
		||||
	} else if (isUndo(activity)) {
 | 
			
		||||
		await undo(actor, activity);
 | 
			
		||||
	} else if (isBlock(activity)) {
 | 
			
		||||
		await block(actor, activity);
 | 
			
		||||
	} else if (isFlag(activity)) {
 | 
			
		||||
		await flag(actor, activity);
 | 
			
		||||
	} else {
 | 
			
		||||
		apLogger.warn(`unrecognized activity type: ${(activity as any).type}`);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								packages/backend/src/remote/activitypub/kernel/like.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								packages/backend/src/remote/activitypub/kernel/like.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { ILike, getApId } from '../type';
 | 
			
		||||
import create from '@/services/note/reaction/create';
 | 
			
		||||
import { fetchNote, extractEmojis } from '../models/note';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: ILike) => {
 | 
			
		||||
	const targetUri = getApId(activity.object);
 | 
			
		||||
 | 
			
		||||
	const note = await fetchNote(targetUri);
 | 
			
		||||
	if (!note) return `skip: target note not found ${targetUri}`;
 | 
			
		||||
 | 
			
		||||
	await extractEmojis(activity.tag || [], actor.host).catch(() => null);
 | 
			
		||||
 | 
			
		||||
	return await create(actor, note, activity._misskey_reaction || activity.content || activity.name).catch(e => {
 | 
			
		||||
		if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
 | 
			
		||||
			return 'skip: already reacted';
 | 
			
		||||
		} else {
 | 
			
		||||
			throw e;
 | 
			
		||||
		}
 | 
			
		||||
	}).then(() => 'ok');
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										27
									
								
								packages/backend/src/remote/activitypub/kernel/read.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								packages/backend/src/remote/activitypub/kernel/read.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { IRead, getApId } from '../type';
 | 
			
		||||
import { isSelfHost, extractDbHost } from '@/misc/convert-host';
 | 
			
		||||
import { MessagingMessages } from '@/models/index';
 | 
			
		||||
import { readUserMessagingMessage } from '../../../server/api/common/read-messaging-message';
 | 
			
		||||
 | 
			
		||||
export const performReadActivity = async (actor: IRemoteUser, activity: IRead): Promise<string> => {
 | 
			
		||||
	const id = await getApId(activity.object);
 | 
			
		||||
 | 
			
		||||
	if (!isSelfHost(extractDbHost(id))) {
 | 
			
		||||
		return `skip: Read to foreign host (${id})`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const messageId = id.split('/').pop();
 | 
			
		||||
 | 
			
		||||
	const message = await MessagingMessages.findOne(messageId);
 | 
			
		||||
	if (message == null) {
 | 
			
		||||
		return `skip: message not found`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (actor.id != message.recipientId) {
 | 
			
		||||
		return `skip: actor is not a message recipient`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	await readUserMessagingMessage(message.recipientId!, message.userId, [message.id]);
 | 
			
		||||
	return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import reject from '@/services/following/requests/reject';
 | 
			
		||||
import { IFollow } from '../../type';
 | 
			
		||||
import DbResolver from '../../db-resolver';
 | 
			
		||||
import { relayRejected } from '@/services/relay';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => {
 | 
			
		||||
	// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
 | 
			
		||||
 | 
			
		||||
	const dbResolver = new DbResolver();
 | 
			
		||||
	const follower = await dbResolver.getUserFromApId(activity.actor);
 | 
			
		||||
 | 
			
		||||
	if (follower == null) {
 | 
			
		||||
		return `skip: follower not found`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (follower.host != null) {
 | 
			
		||||
		return `skip: follower is not a local user`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// relay
 | 
			
		||||
	const match = activity.id?.match(/follow-relay\/(\w+)/);
 | 
			
		||||
	if (match) {
 | 
			
		||||
		return await relayRejected(match[1]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	await reject(actor, follower);
 | 
			
		||||
	return `ok`;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
import Resolver from '../../resolver';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import rejectFollow from './follow';
 | 
			
		||||
import { IReject, isFollow, getApType } from '../../type';
 | 
			
		||||
import { apLogger } from '../../logger';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IReject): Promise<string> => {
 | 
			
		||||
	const uri = activity.id || activity;
 | 
			
		||||
 | 
			
		||||
	logger.info(`Reject: ${uri}`);
 | 
			
		||||
 | 
			
		||||
	const resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const object = await resolver.resolve(activity.object).catch(e => {
 | 
			
		||||
		logger.error(`Resolution failed: ${e}`);
 | 
			
		||||
		throw e;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (isFollow(object)) return await rejectFollow(actor, object);
 | 
			
		||||
 | 
			
		||||
	return `skip: Unknown Reject type: ${getApType(object)}`;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { IRemove } from '../../type';
 | 
			
		||||
import { resolveNote } from '../../models/note';
 | 
			
		||||
import { removePinned } from '@/services/i/pin';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IRemove): Promise<void> => {
 | 
			
		||||
	if ('actor' in activity && actor.uri !== activity.actor) {
 | 
			
		||||
		throw new Error('invalid actor');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (activity.target == null) {
 | 
			
		||||
		throw new Error('target is null');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (activity.target === actor.featured) {
 | 
			
		||||
		const note = await resolveNote(activity.object);
 | 
			
		||||
		if (note == null) throw new Error('note not found');
 | 
			
		||||
		await removePinned(actor, note.id);
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	throw new Error(`unknown target: ${activity.target}`);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
import { Notes } from '@/models/index';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { IAnnounce, getApId } from '../../type';
 | 
			
		||||
import deleteNote from '@/services/note/delete';
 | 
			
		||||
 | 
			
		||||
export const undoAnnounce = async (actor: IRemoteUser, activity: IAnnounce): Promise<string> => {
 | 
			
		||||
	const uri = getApId(activity);
 | 
			
		||||
 | 
			
		||||
	const note = await Notes.findOne({
 | 
			
		||||
		uri
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (!note) return 'skip: no such Announce';
 | 
			
		||||
 | 
			
		||||
	await deleteNote(actor, note);
 | 
			
		||||
	return 'ok: deleted';
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										20
									
								
								packages/backend/src/remote/activitypub/kernel/undo/block.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								packages/backend/src/remote/activitypub/kernel/undo/block.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
import { IBlock } from '../../type';
 | 
			
		||||
import unblock from '@/services/blocking/delete';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import DbResolver from '../../db-resolver';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IBlock): Promise<string> => {
 | 
			
		||||
	const dbResolver = new DbResolver();
 | 
			
		||||
	const blockee = await dbResolver.getUserFromApId(activity.object);
 | 
			
		||||
 | 
			
		||||
	if (blockee == null) {
 | 
			
		||||
		return `skip: blockee not found`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (blockee.host != null) {
 | 
			
		||||
		return `skip: ブロック解除しようとしているユーザーはローカルユーザーではありません`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	await unblock(actor, blockee);
 | 
			
		||||
	return `ok`;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
import unfollow from '@/services/following/delete';
 | 
			
		||||
import cancelRequest from '@/services/following/requests/cancel';
 | 
			
		||||
import { IFollow } from '../../type';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { FollowRequests, Followings } from '@/models/index';
 | 
			
		||||
import DbResolver from '../../db-resolver';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => {
 | 
			
		||||
	const dbResolver = new DbResolver();
 | 
			
		||||
 | 
			
		||||
	const followee = await dbResolver.getUserFromApId(activity.object);
 | 
			
		||||
	if (followee == null) {
 | 
			
		||||
		return `skip: followee not found`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (followee.host != null) {
 | 
			
		||||
		return `skip: フォロー解除しようとしているユーザーはローカルユーザーではありません`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const req = await FollowRequests.findOne({
 | 
			
		||||
		followerId: actor.id,
 | 
			
		||||
		followeeId: followee.id
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const following = await Followings.findOne({
 | 
			
		||||
		followerId: actor.id,
 | 
			
		||||
		followeeId: followee.id
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (req) {
 | 
			
		||||
		await cancelRequest(followee, actor);
 | 
			
		||||
		return `ok: follow request canceled`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (following) {
 | 
			
		||||
		await unfollow(actor, followee);
 | 
			
		||||
		return `ok: unfollowed`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return `skip: リクエストもフォローもされていない`;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										34
									
								
								packages/backend/src/remote/activitypub/kernel/undo/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								packages/backend/src/remote/activitypub/kernel/undo/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { IUndo, isFollow, isBlock, isLike, isAnnounce, getApType } from '../../type';
 | 
			
		||||
import unfollow from './follow';
 | 
			
		||||
import unblock from './block';
 | 
			
		||||
import undoLike from './like';
 | 
			
		||||
import { undoAnnounce } from './announce';
 | 
			
		||||
import Resolver from '../../resolver';
 | 
			
		||||
import { apLogger } from '../../logger';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IUndo): Promise<string> => {
 | 
			
		||||
	if ('actor' in activity && actor.uri !== activity.actor) {
 | 
			
		||||
		throw new Error('invalid actor');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const uri = activity.id || activity;
 | 
			
		||||
 | 
			
		||||
	logger.info(`Undo: ${uri}`);
 | 
			
		||||
 | 
			
		||||
	const resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const object = await resolver.resolve(activity.object).catch(e => {
 | 
			
		||||
		logger.error(`Resolution failed: ${e}`);
 | 
			
		||||
		throw e;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (isFollow(object)) return await unfollow(actor, object);
 | 
			
		||||
	if (isBlock(object)) return await unblock(actor, object);
 | 
			
		||||
	if (isLike(object)) return await undoLike(actor, object);
 | 
			
		||||
	if (isAnnounce(object)) return await undoAnnounce(actor, object);
 | 
			
		||||
 | 
			
		||||
	return `skip: unknown object type ${getApType(object)}`;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										21
									
								
								packages/backend/src/remote/activitypub/kernel/undo/like.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								packages/backend/src/remote/activitypub/kernel/undo/like.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { ILike, getApId } from '../../type';
 | 
			
		||||
import deleteReaction from '@/services/note/reaction/delete';
 | 
			
		||||
import { fetchNote } from '../../models/note';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Process Undo.Like activity
 | 
			
		||||
 */
 | 
			
		||||
export default async (actor: IRemoteUser, activity: ILike) => {
 | 
			
		||||
	const targetUri = getApId(activity.object);
 | 
			
		||||
 | 
			
		||||
	const note = await fetchNote(targetUri);
 | 
			
		||||
	if (!note) return `skip: target note not found ${targetUri}`;
 | 
			
		||||
 | 
			
		||||
	await deleteReaction(actor, note).catch(e => {
 | 
			
		||||
		if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return;
 | 
			
		||||
		throw e;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	return `ok`;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { getApType, IUpdate, isActor } from '../../type';
 | 
			
		||||
import { apLogger } from '../../logger';
 | 
			
		||||
import { updateQuestion } from '../../models/question';
 | 
			
		||||
import Resolver from '../../resolver';
 | 
			
		||||
import { updatePerson } from '../../models/person';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Updateアクティビティを捌きます
 | 
			
		||||
 */
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IUpdate): Promise<string> => {
 | 
			
		||||
	if ('actor' in activity && actor.uri !== activity.actor) {
 | 
			
		||||
		return `skip: invalid actor`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	apLogger.debug('Update');
 | 
			
		||||
 | 
			
		||||
	const resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const object = await resolver.resolve(activity.object).catch(e => {
 | 
			
		||||
		apLogger.error(`Resolution failed: ${e}`);
 | 
			
		||||
		throw e;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (isActor(object)) {
 | 
			
		||||
		await updatePerson(actor.uri!, resolver, object);
 | 
			
		||||
		return `ok: Person updated`;
 | 
			
		||||
	} else if (getApType(object) === 'Question') {
 | 
			
		||||
		await updateQuestion(object).catch(e => console.log(e));
 | 
			
		||||
		return `ok: Question updated`;
 | 
			
		||||
	} else {
 | 
			
		||||
		return `skip: Unknown type: ${getApType(object)}`;
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										3
									
								
								packages/backend/src/remote/activitypub/logger.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/backend/src/remote/activitypub/logger.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
import { remoteLogger } from '../logger';
 | 
			
		||||
 | 
			
		||||
export const apLogger = remoteLogger.createSubLogger('ap', 'magenta');
 | 
			
		||||
							
								
								
									
										526
									
								
								packages/backend/src/remote/activitypub/misc/contexts.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										526
									
								
								packages/backend/src/remote/activitypub/misc/contexts.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,526 @@
 | 
			
		|||
/* tslint:disable:quotemark indent */
 | 
			
		||||
const id_v1 = {
 | 
			
		||||
  "@context": {
 | 
			
		||||
    "id": "@id",
 | 
			
		||||
    "type": "@type",
 | 
			
		||||
 | 
			
		||||
    "cred": "https://w3id.org/credentials#",
 | 
			
		||||
    "dc": "http://purl.org/dc/terms/",
 | 
			
		||||
    "identity": "https://w3id.org/identity#",
 | 
			
		||||
    "perm": "https://w3id.org/permissions#",
 | 
			
		||||
    "ps": "https://w3id.org/payswarm#",
 | 
			
		||||
    "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
 | 
			
		||||
    "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
 | 
			
		||||
    "sec": "https://w3id.org/security#",
 | 
			
		||||
    "schema": "http://schema.org/",
 | 
			
		||||
    "xsd": "http://www.w3.org/2001/XMLSchema#",
 | 
			
		||||
 | 
			
		||||
    "Group": "https://www.w3.org/ns/activitystreams#Group",
 | 
			
		||||
 | 
			
		||||
    "claim": {"@id": "cred:claim", "@type": "@id"},
 | 
			
		||||
    "credential": {"@id": "cred:credential", "@type": "@id"},
 | 
			
		||||
    "issued": {"@id": "cred:issued", "@type": "xsd:dateTime"},
 | 
			
		||||
    "issuer": {"@id": "cred:issuer", "@type": "@id"},
 | 
			
		||||
    "recipient": {"@id": "cred:recipient", "@type": "@id"},
 | 
			
		||||
    "Credential": "cred:Credential",
 | 
			
		||||
    "CryptographicKeyCredential": "cred:CryptographicKeyCredential",
 | 
			
		||||
 | 
			
		||||
    "about": {"@id": "schema:about", "@type": "@id"},
 | 
			
		||||
    "address": {"@id": "schema:address", "@type": "@id"},
 | 
			
		||||
    "addressCountry": "schema:addressCountry",
 | 
			
		||||
    "addressLocality": "schema:addressLocality",
 | 
			
		||||
    "addressRegion": "schema:addressRegion",
 | 
			
		||||
    "comment": "rdfs:comment",
 | 
			
		||||
    "created": {"@id": "dc:created", "@type": "xsd:dateTime"},
 | 
			
		||||
    "creator": {"@id": "dc:creator", "@type": "@id"},
 | 
			
		||||
    "description": "schema:description",
 | 
			
		||||
    "email": "schema:email",
 | 
			
		||||
    "familyName": "schema:familyName",
 | 
			
		||||
    "givenName": "schema:givenName",
 | 
			
		||||
    "image": {"@id": "schema:image", "@type": "@id"},
 | 
			
		||||
    "label": "rdfs:label",
 | 
			
		||||
    "name": "schema:name",
 | 
			
		||||
    "postalCode": "schema:postalCode",
 | 
			
		||||
    "streetAddress": "schema:streetAddress",
 | 
			
		||||
    "title": "dc:title",
 | 
			
		||||
    "url": {"@id": "schema:url", "@type": "@id"},
 | 
			
		||||
    "Person": "schema:Person",
 | 
			
		||||
    "PostalAddress": "schema:PostalAddress",
 | 
			
		||||
    "Organization": "schema:Organization",
 | 
			
		||||
 | 
			
		||||
    "identityService": {"@id": "identity:identityService", "@type": "@id"},
 | 
			
		||||
    "idp": {"@id": "identity:idp", "@type": "@id"},
 | 
			
		||||
    "Identity": "identity:Identity",
 | 
			
		||||
 | 
			
		||||
    "paymentProcessor": "ps:processor",
 | 
			
		||||
    "preferences": {"@id": "ps:preferences", "@type": "@vocab"},
 | 
			
		||||
 | 
			
		||||
    "cipherAlgorithm": "sec:cipherAlgorithm",
 | 
			
		||||
    "cipherData": "sec:cipherData",
 | 
			
		||||
    "cipherKey": "sec:cipherKey",
 | 
			
		||||
    "digestAlgorithm": "sec:digestAlgorithm",
 | 
			
		||||
    "digestValue": "sec:digestValue",
 | 
			
		||||
    "domain": "sec:domain",
 | 
			
		||||
    "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"},
 | 
			
		||||
    "initializationVector": "sec:initializationVector",
 | 
			
		||||
    "member": {"@id": "schema:member", "@type": "@id"},
 | 
			
		||||
    "memberOf": {"@id": "schema:memberOf", "@type": "@id"},
 | 
			
		||||
    "nonce": "sec:nonce",
 | 
			
		||||
    "normalizationAlgorithm": "sec:normalizationAlgorithm",
 | 
			
		||||
    "owner": {"@id": "sec:owner", "@type": "@id"},
 | 
			
		||||
    "password": "sec:password",
 | 
			
		||||
    "privateKey": {"@id": "sec:privateKey", "@type": "@id"},
 | 
			
		||||
    "privateKeyPem": "sec:privateKeyPem",
 | 
			
		||||
    "publicKey": {"@id": "sec:publicKey", "@type": "@id"},
 | 
			
		||||
    "publicKeyPem": "sec:publicKeyPem",
 | 
			
		||||
    "publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"},
 | 
			
		||||
    "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"},
 | 
			
		||||
    "signature": "sec:signature",
 | 
			
		||||
    "signatureAlgorithm": "sec:signatureAlgorithm",
 | 
			
		||||
    "signatureValue": "sec:signatureValue",
 | 
			
		||||
    "CryptographicKey": "sec:Key",
 | 
			
		||||
    "EncryptedMessage": "sec:EncryptedMessage",
 | 
			
		||||
    "GraphSignature2012": "sec:GraphSignature2012",
 | 
			
		||||
    "LinkedDataSignature2015": "sec:LinkedDataSignature2015",
 | 
			
		||||
 | 
			
		||||
    "accessControl": {"@id": "perm:accessControl", "@type": "@id"},
 | 
			
		||||
    "writePermission": {"@id": "perm:writePermission", "@type": "@id"}
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const security_v1 = {
 | 
			
		||||
  "@context": {
 | 
			
		||||
    "id": "@id",
 | 
			
		||||
    "type": "@type",
 | 
			
		||||
 | 
			
		||||
    "dc": "http://purl.org/dc/terms/",
 | 
			
		||||
    "sec": "https://w3id.org/security#",
 | 
			
		||||
    "xsd": "http://www.w3.org/2001/XMLSchema#",
 | 
			
		||||
 | 
			
		||||
    "EcdsaKoblitzSignature2016": "sec:EcdsaKoblitzSignature2016",
 | 
			
		||||
    "Ed25519Signature2018": "sec:Ed25519Signature2018",
 | 
			
		||||
    "EncryptedMessage": "sec:EncryptedMessage",
 | 
			
		||||
    "GraphSignature2012": "sec:GraphSignature2012",
 | 
			
		||||
    "LinkedDataSignature2015": "sec:LinkedDataSignature2015",
 | 
			
		||||
    "LinkedDataSignature2016": "sec:LinkedDataSignature2016",
 | 
			
		||||
    "CryptographicKey": "sec:Key",
 | 
			
		||||
 | 
			
		||||
    "authenticationTag": "sec:authenticationTag",
 | 
			
		||||
    "canonicalizationAlgorithm": "sec:canonicalizationAlgorithm",
 | 
			
		||||
    "cipherAlgorithm": "sec:cipherAlgorithm",
 | 
			
		||||
    "cipherData": "sec:cipherData",
 | 
			
		||||
    "cipherKey": "sec:cipherKey",
 | 
			
		||||
    "created": {"@id": "dc:created", "@type": "xsd:dateTime"},
 | 
			
		||||
    "creator": {"@id": "dc:creator", "@type": "@id"},
 | 
			
		||||
    "digestAlgorithm": "sec:digestAlgorithm",
 | 
			
		||||
    "digestValue": "sec:digestValue",
 | 
			
		||||
    "domain": "sec:domain",
 | 
			
		||||
    "encryptionKey": "sec:encryptionKey",
 | 
			
		||||
    "expiration": {"@id": "sec:expiration", "@type": "xsd:dateTime"},
 | 
			
		||||
    "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"},
 | 
			
		||||
    "initializationVector": "sec:initializationVector",
 | 
			
		||||
    "iterationCount": "sec:iterationCount",
 | 
			
		||||
    "nonce": "sec:nonce",
 | 
			
		||||
    "normalizationAlgorithm": "sec:normalizationAlgorithm",
 | 
			
		||||
    "owner": {"@id": "sec:owner", "@type": "@id"},
 | 
			
		||||
    "password": "sec:password",
 | 
			
		||||
    "privateKey": {"@id": "sec:privateKey", "@type": "@id"},
 | 
			
		||||
    "privateKeyPem": "sec:privateKeyPem",
 | 
			
		||||
    "publicKey": {"@id": "sec:publicKey", "@type": "@id"},
 | 
			
		||||
    "publicKeyBase58": "sec:publicKeyBase58",
 | 
			
		||||
    "publicKeyPem": "sec:publicKeyPem",
 | 
			
		||||
    "publicKeyWif": "sec:publicKeyWif",
 | 
			
		||||
    "publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"},
 | 
			
		||||
    "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"},
 | 
			
		||||
    "salt": "sec:salt",
 | 
			
		||||
    "signature": "sec:signature",
 | 
			
		||||
    "signatureAlgorithm": "sec:signingAlgorithm",
 | 
			
		||||
    "signatureValue": "sec:signatureValue"
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const activitystreams = {
 | 
			
		||||
  "@context": {
 | 
			
		||||
    "@vocab": "_:",
 | 
			
		||||
    "xsd": "http://www.w3.org/2001/XMLSchema#",
 | 
			
		||||
    "as": "https://www.w3.org/ns/activitystreams#",
 | 
			
		||||
    "ldp": "http://www.w3.org/ns/ldp#",
 | 
			
		||||
    "vcard": "http://www.w3.org/2006/vcard/ns#",
 | 
			
		||||
    "id": "@id",
 | 
			
		||||
    "type": "@type",
 | 
			
		||||
    "Accept": "as:Accept",
 | 
			
		||||
    "Activity": "as:Activity",
 | 
			
		||||
    "IntransitiveActivity": "as:IntransitiveActivity",
 | 
			
		||||
    "Add": "as:Add",
 | 
			
		||||
    "Announce": "as:Announce",
 | 
			
		||||
    "Application": "as:Application",
 | 
			
		||||
    "Arrive": "as:Arrive",
 | 
			
		||||
    "Article": "as:Article",
 | 
			
		||||
    "Audio": "as:Audio",
 | 
			
		||||
    "Block": "as:Block",
 | 
			
		||||
    "Collection": "as:Collection",
 | 
			
		||||
    "CollectionPage": "as:CollectionPage",
 | 
			
		||||
    "Relationship": "as:Relationship",
 | 
			
		||||
    "Create": "as:Create",
 | 
			
		||||
    "Delete": "as:Delete",
 | 
			
		||||
    "Dislike": "as:Dislike",
 | 
			
		||||
    "Document": "as:Document",
 | 
			
		||||
    "Event": "as:Event",
 | 
			
		||||
    "Follow": "as:Follow",
 | 
			
		||||
    "Flag": "as:Flag",
 | 
			
		||||
    "Group": "as:Group",
 | 
			
		||||
    "Ignore": "as:Ignore",
 | 
			
		||||
    "Image": "as:Image",
 | 
			
		||||
    "Invite": "as:Invite",
 | 
			
		||||
    "Join": "as:Join",
 | 
			
		||||
    "Leave": "as:Leave",
 | 
			
		||||
    "Like": "as:Like",
 | 
			
		||||
    "Link": "as:Link",
 | 
			
		||||
    "Mention": "as:Mention",
 | 
			
		||||
    "Note": "as:Note",
 | 
			
		||||
    "Object": "as:Object",
 | 
			
		||||
    "Offer": "as:Offer",
 | 
			
		||||
    "OrderedCollection": "as:OrderedCollection",
 | 
			
		||||
    "OrderedCollectionPage": "as:OrderedCollectionPage",
 | 
			
		||||
    "Organization": "as:Organization",
 | 
			
		||||
    "Page": "as:Page",
 | 
			
		||||
    "Person": "as:Person",
 | 
			
		||||
    "Place": "as:Place",
 | 
			
		||||
    "Profile": "as:Profile",
 | 
			
		||||
    "Question": "as:Question",
 | 
			
		||||
    "Reject": "as:Reject",
 | 
			
		||||
    "Remove": "as:Remove",
 | 
			
		||||
    "Service": "as:Service",
 | 
			
		||||
    "TentativeAccept": "as:TentativeAccept",
 | 
			
		||||
    "TentativeReject": "as:TentativeReject",
 | 
			
		||||
    "Tombstone": "as:Tombstone",
 | 
			
		||||
    "Undo": "as:Undo",
 | 
			
		||||
    "Update": "as:Update",
 | 
			
		||||
    "Video": "as:Video",
 | 
			
		||||
    "View": "as:View",
 | 
			
		||||
    "Listen": "as:Listen",
 | 
			
		||||
    "Read": "as:Read",
 | 
			
		||||
    "Move": "as:Move",
 | 
			
		||||
    "Travel": "as:Travel",
 | 
			
		||||
    "IsFollowing": "as:IsFollowing",
 | 
			
		||||
    "IsFollowedBy": "as:IsFollowedBy",
 | 
			
		||||
    "IsContact": "as:IsContact",
 | 
			
		||||
    "IsMember": "as:IsMember",
 | 
			
		||||
    "subject": {
 | 
			
		||||
      "@id": "as:subject",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "relationship": {
 | 
			
		||||
      "@id": "as:relationship",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "actor": {
 | 
			
		||||
      "@id": "as:actor",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "attributedTo": {
 | 
			
		||||
      "@id": "as:attributedTo",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "attachment": {
 | 
			
		||||
      "@id": "as:attachment",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "bcc": {
 | 
			
		||||
      "@id": "as:bcc",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "bto": {
 | 
			
		||||
      "@id": "as:bto",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "cc": {
 | 
			
		||||
      "@id": "as:cc",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "context": {
 | 
			
		||||
      "@id": "as:context",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "current": {
 | 
			
		||||
      "@id": "as:current",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "first": {
 | 
			
		||||
      "@id": "as:first",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "generator": {
 | 
			
		||||
      "@id": "as:generator",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "icon": {
 | 
			
		||||
      "@id": "as:icon",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "image": {
 | 
			
		||||
      "@id": "as:image",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "inReplyTo": {
 | 
			
		||||
      "@id": "as:inReplyTo",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "items": {
 | 
			
		||||
      "@id": "as:items",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "instrument": {
 | 
			
		||||
      "@id": "as:instrument",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "orderedItems": {
 | 
			
		||||
      "@id": "as:items",
 | 
			
		||||
      "@type": "@id",
 | 
			
		||||
      "@container": "@list"
 | 
			
		||||
    },
 | 
			
		||||
    "last": {
 | 
			
		||||
      "@id": "as:last",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "location": {
 | 
			
		||||
      "@id": "as:location",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "next": {
 | 
			
		||||
      "@id": "as:next",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "object": {
 | 
			
		||||
      "@id": "as:object",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "oneOf": {
 | 
			
		||||
      "@id": "as:oneOf",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "anyOf": {
 | 
			
		||||
      "@id": "as:anyOf",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "closed": {
 | 
			
		||||
      "@id": "as:closed",
 | 
			
		||||
      "@type": "xsd:dateTime"
 | 
			
		||||
    },
 | 
			
		||||
    "origin": {
 | 
			
		||||
      "@id": "as:origin",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "accuracy": {
 | 
			
		||||
      "@id": "as:accuracy",
 | 
			
		||||
      "@type": "xsd:float"
 | 
			
		||||
    },
 | 
			
		||||
    "prev": {
 | 
			
		||||
      "@id": "as:prev",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "preview": {
 | 
			
		||||
      "@id": "as:preview",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "replies": {
 | 
			
		||||
      "@id": "as:replies",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "result": {
 | 
			
		||||
      "@id": "as:result",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "audience": {
 | 
			
		||||
      "@id": "as:audience",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "partOf": {
 | 
			
		||||
      "@id": "as:partOf",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "tag": {
 | 
			
		||||
      "@id": "as:tag",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "target": {
 | 
			
		||||
      "@id": "as:target",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "to": {
 | 
			
		||||
      "@id": "as:to",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "url": {
 | 
			
		||||
      "@id": "as:url",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "altitude": {
 | 
			
		||||
      "@id": "as:altitude",
 | 
			
		||||
      "@type": "xsd:float"
 | 
			
		||||
    },
 | 
			
		||||
    "content": "as:content",
 | 
			
		||||
    "contentMap": {
 | 
			
		||||
      "@id": "as:content",
 | 
			
		||||
      "@container": "@language"
 | 
			
		||||
    },
 | 
			
		||||
    "name": "as:name",
 | 
			
		||||
    "nameMap": {
 | 
			
		||||
      "@id": "as:name",
 | 
			
		||||
      "@container": "@language"
 | 
			
		||||
    },
 | 
			
		||||
    "duration": {
 | 
			
		||||
      "@id": "as:duration",
 | 
			
		||||
      "@type": "xsd:duration"
 | 
			
		||||
    },
 | 
			
		||||
    "endTime": {
 | 
			
		||||
      "@id": "as:endTime",
 | 
			
		||||
      "@type": "xsd:dateTime"
 | 
			
		||||
    },
 | 
			
		||||
    "height": {
 | 
			
		||||
      "@id": "as:height",
 | 
			
		||||
      "@type": "xsd:nonNegativeInteger"
 | 
			
		||||
    },
 | 
			
		||||
    "href": {
 | 
			
		||||
      "@id": "as:href",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "hreflang": "as:hreflang",
 | 
			
		||||
    "latitude": {
 | 
			
		||||
      "@id": "as:latitude",
 | 
			
		||||
      "@type": "xsd:float"
 | 
			
		||||
    },
 | 
			
		||||
    "longitude": {
 | 
			
		||||
      "@id": "as:longitude",
 | 
			
		||||
      "@type": "xsd:float"
 | 
			
		||||
    },
 | 
			
		||||
    "mediaType": "as:mediaType",
 | 
			
		||||
    "published": {
 | 
			
		||||
      "@id": "as:published",
 | 
			
		||||
      "@type": "xsd:dateTime"
 | 
			
		||||
    },
 | 
			
		||||
    "radius": {
 | 
			
		||||
      "@id": "as:radius",
 | 
			
		||||
      "@type": "xsd:float"
 | 
			
		||||
    },
 | 
			
		||||
    "rel": "as:rel",
 | 
			
		||||
    "startIndex": {
 | 
			
		||||
      "@id": "as:startIndex",
 | 
			
		||||
      "@type": "xsd:nonNegativeInteger"
 | 
			
		||||
    },
 | 
			
		||||
    "startTime": {
 | 
			
		||||
      "@id": "as:startTime",
 | 
			
		||||
      "@type": "xsd:dateTime"
 | 
			
		||||
    },
 | 
			
		||||
    "summary": "as:summary",
 | 
			
		||||
    "summaryMap": {
 | 
			
		||||
      "@id": "as:summary",
 | 
			
		||||
      "@container": "@language"
 | 
			
		||||
    },
 | 
			
		||||
    "totalItems": {
 | 
			
		||||
      "@id": "as:totalItems",
 | 
			
		||||
      "@type": "xsd:nonNegativeInteger"
 | 
			
		||||
    },
 | 
			
		||||
    "units": "as:units",
 | 
			
		||||
    "updated": {
 | 
			
		||||
      "@id": "as:updated",
 | 
			
		||||
      "@type": "xsd:dateTime"
 | 
			
		||||
    },
 | 
			
		||||
    "width": {
 | 
			
		||||
      "@id": "as:width",
 | 
			
		||||
      "@type": "xsd:nonNegativeInteger"
 | 
			
		||||
    },
 | 
			
		||||
    "describes": {
 | 
			
		||||
      "@id": "as:describes",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "formerType": {
 | 
			
		||||
      "@id": "as:formerType",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "deleted": {
 | 
			
		||||
      "@id": "as:deleted",
 | 
			
		||||
      "@type": "xsd:dateTime"
 | 
			
		||||
    },
 | 
			
		||||
    "inbox": {
 | 
			
		||||
      "@id": "ldp:inbox",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "outbox": {
 | 
			
		||||
      "@id": "as:outbox",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "following": {
 | 
			
		||||
      "@id": "as:following",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "followers": {
 | 
			
		||||
      "@id": "as:followers",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "streams": {
 | 
			
		||||
      "@id": "as:streams",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "preferredUsername": "as:preferredUsername",
 | 
			
		||||
    "endpoints": {
 | 
			
		||||
      "@id": "as:endpoints",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "uploadMedia": {
 | 
			
		||||
      "@id": "as:uploadMedia",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "proxyUrl": {
 | 
			
		||||
      "@id": "as:proxyUrl",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "liked": {
 | 
			
		||||
      "@id": "as:liked",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "oauthAuthorizationEndpoint": {
 | 
			
		||||
      "@id": "as:oauthAuthorizationEndpoint",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "oauthTokenEndpoint": {
 | 
			
		||||
      "@id": "as:oauthTokenEndpoint",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "provideClientKey": {
 | 
			
		||||
      "@id": "as:provideClientKey",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "signClientKey": {
 | 
			
		||||
      "@id": "as:signClientKey",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "sharedInbox": {
 | 
			
		||||
      "@id": "as:sharedInbox",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "Public": {
 | 
			
		||||
      "@id": "as:Public",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "source": "as:source",
 | 
			
		||||
    "likes": {
 | 
			
		||||
      "@id": "as:likes",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "shares": {
 | 
			
		||||
      "@id": "as:shares",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    },
 | 
			
		||||
    "alsoKnownAs": {
 | 
			
		||||
      "@id": "as:alsoKnownAs",
 | 
			
		||||
      "@type": "@id"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const CONTEXTS: Record<string, any> = {
 | 
			
		||||
  "https://w3id.org/identity/v1": id_v1,
 | 
			
		||||
  "https://w3id.org/security/v1": security_v1,
 | 
			
		||||
  "https://www.w3.org/ns/activitystreams": activitystreams,
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
import * as mfm from 'mfm-js';
 | 
			
		||||
import { Note } from '@/models/entities/note';
 | 
			
		||||
import { toHtml } from '../../../mfm/to-html';
 | 
			
		||||
 | 
			
		||||
export default function(note: Note) {
 | 
			
		||||
	let html = note.text ? toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)) : null;
 | 
			
		||||
	if (html == null) html = '<p>.</p>';
 | 
			
		||||
 | 
			
		||||
	return html;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import { IObject } from '../type';
 | 
			
		||||
import { extractApHashtagObjects } from '../models/tag';
 | 
			
		||||
import { fromHtml } from '../../../mfm/from-html';
 | 
			
		||||
 | 
			
		||||
export function htmlToMfm(html: string, tag?: IObject | IObject[]) {
 | 
			
		||||
	const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null);
 | 
			
		||||
 | 
			
		||||
	return fromHtml(html, hashtagNames);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										134
									
								
								packages/backend/src/remote/activitypub/misc/ld-signature.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								packages/backend/src/remote/activitypub/misc/ld-signature.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,134 @@
 | 
			
		|||
import * as crypto from 'crypto';
 | 
			
		||||
import * as jsonld from 'jsonld';
 | 
			
		||||
import { CONTEXTS } from './contexts';
 | 
			
		||||
import fetch from 'node-fetch';
 | 
			
		||||
import { httpAgent, httpsAgent } from '@/misc/fetch';
 | 
			
		||||
 | 
			
		||||
// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017
 | 
			
		||||
 | 
			
		||||
export class LdSignature {
 | 
			
		||||
	public debug = false;
 | 
			
		||||
	public preLoad = true;
 | 
			
		||||
	public loderTimeout = 10 * 1000;
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise<any> {
 | 
			
		||||
		const options = {
 | 
			
		||||
			type: 'RsaSignature2017',
 | 
			
		||||
			creator,
 | 
			
		||||
			domain,
 | 
			
		||||
			nonce: crypto.randomBytes(16).toString('hex'),
 | 
			
		||||
			created: (created || new Date()).toISOString()
 | 
			
		||||
		} as {
 | 
			
		||||
			type: string;
 | 
			
		||||
			creator: string;
 | 
			
		||||
			domain: string;
 | 
			
		||||
			nonce: string;
 | 
			
		||||
			created: string;
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		if (!domain) {
 | 
			
		||||
			delete options.domain;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const toBeSigned = await this.createVerifyData(data, options);
 | 
			
		||||
 | 
			
		||||
		const signer = crypto.createSign('sha256');
 | 
			
		||||
		signer.update(toBeSigned);
 | 
			
		||||
		signer.end();
 | 
			
		||||
 | 
			
		||||
		const signature = signer.sign(privateKey);
 | 
			
		||||
 | 
			
		||||
		return {
 | 
			
		||||
			...data,
 | 
			
		||||
			signature: {
 | 
			
		||||
				...options,
 | 
			
		||||
				signatureValue: signature.toString('base64')
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async verifyRsaSignature2017(data: any, publicKey: string): Promise<boolean> {
 | 
			
		||||
		const toBeSigned = await this.createVerifyData(data, data.signature);
 | 
			
		||||
		const verifier = crypto.createVerify('sha256');
 | 
			
		||||
		verifier.update(toBeSigned);
 | 
			
		||||
		return verifier.verify(publicKey, data.signature.signatureValue, 'base64');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async createVerifyData(data: any, options: any) {
 | 
			
		||||
		const transformedOptions = {
 | 
			
		||||
			...options,
 | 
			
		||||
			'@context': 'https://w3id.org/identity/v1'
 | 
			
		||||
		};
 | 
			
		||||
		delete transformedOptions['type'];
 | 
			
		||||
		delete transformedOptions['id'];
 | 
			
		||||
		delete transformedOptions['signatureValue'];
 | 
			
		||||
		const canonizedOptions = await this.normalize(transformedOptions);
 | 
			
		||||
		const optionsHash = this.sha256(canonizedOptions);
 | 
			
		||||
		const transformedData = { ...data };
 | 
			
		||||
		delete transformedData['signature'];
 | 
			
		||||
		const cannonidedData = await this.normalize(transformedData);
 | 
			
		||||
		if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`);
 | 
			
		||||
		const documentHash = this.sha256(cannonidedData);
 | 
			
		||||
		const verifyData = `${optionsHash}${documentHash}`;
 | 
			
		||||
		return verifyData;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async normalize(data: any) {
 | 
			
		||||
		const customLoader = this.getLoader();
 | 
			
		||||
		return await jsonld.normalize(data, {
 | 
			
		||||
			documentLoader: customLoader
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private getLoader() {
 | 
			
		||||
		return async (url: string): Promise<any> => {
 | 
			
		||||
			if (!url.match('^https?\:\/\/')) throw `Invalid URL ${url}`;
 | 
			
		||||
 | 
			
		||||
			if (this.preLoad) {
 | 
			
		||||
				if (url in CONTEXTS) {
 | 
			
		||||
					if (this.debug) console.debug(`HIT: ${url}`);
 | 
			
		||||
					return {
 | 
			
		||||
						contextUrl: null,
 | 
			
		||||
						document: CONTEXTS[url],
 | 
			
		||||
						documentUrl: url
 | 
			
		||||
					};
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (this.debug) console.debug(`MISS: ${url}`);
 | 
			
		||||
			const document = await this.fetchDocument(url);
 | 
			
		||||
			return {
 | 
			
		||||
				contextUrl: null,
 | 
			
		||||
				document: document,
 | 
			
		||||
				documentUrl: url
 | 
			
		||||
			};
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private async fetchDocument(url: string) {
 | 
			
		||||
		const json = await fetch(url, {
 | 
			
		||||
			headers: {
 | 
			
		||||
				Accept: 'application/ld+json, application/json',
 | 
			
		||||
			},
 | 
			
		||||
			timeout: this.loderTimeout,
 | 
			
		||||
			agent: u => u.protocol == 'http:' ? httpAgent : httpsAgent,
 | 
			
		||||
		}).then(res => {
 | 
			
		||||
			if (!res.ok) {
 | 
			
		||||
				throw `${res.status} ${res.statusText}`;
 | 
			
		||||
			} else {
 | 
			
		||||
				return res.json();
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		return json;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public sha256(data: string): string {
 | 
			
		||||
		const hash = crypto.createHash('sha256');
 | 
			
		||||
		hash.update(data);
 | 
			
		||||
		return hash.digest('hex');
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										5
									
								
								packages/backend/src/remote/activitypub/models/icon.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								packages/backend/src/remote/activitypub/models/icon.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
export type IIcon = {
 | 
			
		||||
	type: string;
 | 
			
		||||
	mediaType?: string;
 | 
			
		||||
	url?: string;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
export type IIdentifier = {
 | 
			
		||||
	type: string;
 | 
			
		||||
	name: string;
 | 
			
		||||
	value: string;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										62
									
								
								packages/backend/src/remote/activitypub/models/image.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								packages/backend/src/remote/activitypub/models/image.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,62 @@
 | 
			
		|||
import uploadFromUrl from '@/services/drive/upload-from-url';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import Resolver from '../resolver';
 | 
			
		||||
import { fetchMeta } from '@/misc/fetch-meta';
 | 
			
		||||
import { apLogger } from '../logger';
 | 
			
		||||
import { DriveFile } from '@/models/entities/drive-file';
 | 
			
		||||
import { DriveFiles } from '@/models/index';
 | 
			
		||||
import { truncate } from '@/misc/truncate';
 | 
			
		||||
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Imageを作成します。
 | 
			
		||||
 */
 | 
			
		||||
export async function createImage(actor: IRemoteUser, value: any): Promise<DriveFile> {
 | 
			
		||||
	// 投稿者が凍結されていたらスキップ
 | 
			
		||||
	if (actor.isSuspended) {
 | 
			
		||||
		throw new Error('actor has been suspended');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const image = await new Resolver().resolve(value) as any;
 | 
			
		||||
 | 
			
		||||
	if (image.url == null) {
 | 
			
		||||
		throw new Error('invalid image: url not privided');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	logger.info(`Creating the Image: ${image.url}`);
 | 
			
		||||
 | 
			
		||||
	const instance = await fetchMeta();
 | 
			
		||||
	const cache = instance.cacheRemoteFiles;
 | 
			
		||||
 | 
			
		||||
	let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache, truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH));
 | 
			
		||||
 | 
			
		||||
	if (file.isLink) {
 | 
			
		||||
		// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、
 | 
			
		||||
		// URLを更新する
 | 
			
		||||
		if (file.url !== image.url) {
 | 
			
		||||
			await DriveFiles.update({ id: file.id }, {
 | 
			
		||||
				url: image.url,
 | 
			
		||||
				uri: image.url
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			file = await DriveFiles.findOneOrFail(file.id);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return file;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Imageを解決します。
 | 
			
		||||
 *
 | 
			
		||||
 * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ
 | 
			
		||||
 * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
 | 
			
		||||
 */
 | 
			
		||||
export async function resolveImage(actor: IRemoteUser, value: any): Promise<DriveFile> {
 | 
			
		||||
	// TODO
 | 
			
		||||
 | 
			
		||||
	// リモートサーバーからフェッチしてきて登録
 | 
			
		||||
	return await createImage(actor, value);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								packages/backend/src/remote/activitypub/models/mention.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								packages/backend/src/remote/activitypub/models/mention.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
import { toArray, unique } from '@/prelude/array';
 | 
			
		||||
import { IObject, isMention, IApMention } from '../type';
 | 
			
		||||
import { resolvePerson } from './person';
 | 
			
		||||
import * as promiseLimit from 'promise-limit';
 | 
			
		||||
import Resolver from '../resolver';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export async function extractApMentions(tags: IObject | IObject[] | null | undefined) {
 | 
			
		||||
	const hrefs = unique(extractApMentionObjects(tags).map(x => x.href as string));
 | 
			
		||||
 | 
			
		||||
	const resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const limit = promiseLimit<User | null>(2);
 | 
			
		||||
	const mentionedUsers = (await Promise.all(
 | 
			
		||||
		hrefs.map(x => limit(() => resolvePerson(x, resolver).catch(() => null)))
 | 
			
		||||
	)).filter((x): x is User => x != null);
 | 
			
		||||
 | 
			
		||||
	return mentionedUsers;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] {
 | 
			
		||||
	if (tags == null) return [];
 | 
			
		||||
	return toArray(tags).filter(isMention);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										356
									
								
								packages/backend/src/remote/activitypub/models/note.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										356
									
								
								packages/backend/src/remote/activitypub/models/note.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,356 @@
 | 
			
		|||
import * as promiseLimit from 'promise-limit';
 | 
			
		||||
 | 
			
		||||
import config from '@/config/index';
 | 
			
		||||
import Resolver from '../resolver';
 | 
			
		||||
import post from '@/services/note/create';
 | 
			
		||||
import { resolvePerson, updatePerson } from './person';
 | 
			
		||||
import { resolveImage } from './image';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { htmlToMfm } from '../misc/html-to-mfm';
 | 
			
		||||
import { extractApHashtags } from './tag';
 | 
			
		||||
import { unique, toArray, toSingle } from '@/prelude/array';
 | 
			
		||||
import { extractPollFromQuestion } from './question';
 | 
			
		||||
import vote from '@/services/note/polls/vote';
 | 
			
		||||
import { apLogger } from '../logger';
 | 
			
		||||
import { DriveFile } from '@/models/entities/drive-file';
 | 
			
		||||
import { deliverQuestionUpdate } from '@/services/note/polls/update';
 | 
			
		||||
import { extractDbHost, toPuny } from '@/misc/convert-host';
 | 
			
		||||
import { Emojis, Polls, MessagingMessages } from '@/models/index';
 | 
			
		||||
import { Note } from '@/models/entities/note';
 | 
			
		||||
import { IObject, getOneApId, getApId, getOneApHrefNullable, validPost, IPost, isEmoji, getApType } from '../type';
 | 
			
		||||
import { Emoji } from '@/models/entities/emoji';
 | 
			
		||||
import { genId } from '@/misc/gen-id';
 | 
			
		||||
import { fetchMeta } from '@/misc/fetch-meta';
 | 
			
		||||
import { getApLock } from '@/misc/app-lock';
 | 
			
		||||
import { createMessage } from '@/services/messages/create';
 | 
			
		||||
import { parseAudience } from '../audience';
 | 
			
		||||
import { extractApMentions } from './mention';
 | 
			
		||||
import DbResolver from '../db-resolver';
 | 
			
		||||
import { StatusError } from '@/misc/fetch';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
export function validateNote(object: any, uri: string) {
 | 
			
		||||
	const expectHost = extractDbHost(uri);
 | 
			
		||||
 | 
			
		||||
	if (object == null) {
 | 
			
		||||
		return new Error('invalid Note: object is null');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!validPost.includes(getApType(object))) {
 | 
			
		||||
		return new Error(`invalid Note: invalid object type ${getApType(object)}`);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (object.id && extractDbHost(object.id) !== expectHost) {
 | 
			
		||||
		return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${extractDbHost(object.id)}`);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (object.attributedTo && extractDbHost(getOneApId(object.attributedTo)) !== expectHost) {
 | 
			
		||||
		return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${extractDbHost(object.attributedTo)}`);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Noteをフェッチします。
 | 
			
		||||
 *
 | 
			
		||||
 * Misskeyに対象のNoteが登録されていればそれを返します。
 | 
			
		||||
 */
 | 
			
		||||
export async function fetchNote(object: string | IObject): Promise<Note | null> {
 | 
			
		||||
	const dbResolver = new DbResolver();
 | 
			
		||||
	return await dbResolver.getNoteFromApId(object);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Noteを作成します。
 | 
			
		||||
 */
 | 
			
		||||
export async function createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<Note | null> {
 | 
			
		||||
	if (resolver == null) resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const object: any = await resolver.resolve(value);
 | 
			
		||||
 | 
			
		||||
	const entryUri = getApId(value);
 | 
			
		||||
	const err = validateNote(object, entryUri);
 | 
			
		||||
	if (err) {
 | 
			
		||||
		logger.error(`${err.message}`, {
 | 
			
		||||
			resolver: {
 | 
			
		||||
				history: resolver.getHistory()
 | 
			
		||||
			},
 | 
			
		||||
			value: value,
 | 
			
		||||
			object: object
 | 
			
		||||
		});
 | 
			
		||||
		throw new Error('invalid note');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const note: IPost = object;
 | 
			
		||||
 | 
			
		||||
	logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
 | 
			
		||||
 | 
			
		||||
	logger.info(`Creating the Note: ${note.id}`);
 | 
			
		||||
 | 
			
		||||
	// 投稿者をフェッチ
 | 
			
		||||
	const actor = await resolvePerson(getOneApId(note.attributedTo), resolver) as IRemoteUser;
 | 
			
		||||
 | 
			
		||||
	// 投稿者が凍結されていたらスキップ
 | 
			
		||||
	if (actor.isSuspended) {
 | 
			
		||||
		throw new Error('actor has been suspended');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const noteAudience = await parseAudience(actor, note.to, note.cc);
 | 
			
		||||
	let visibility = noteAudience.visibility;
 | 
			
		||||
	const visibleUsers = noteAudience.visibleUsers;
 | 
			
		||||
 | 
			
		||||
	// Audience (to, cc) が指定されてなかった場合
 | 
			
		||||
	if (visibility === 'specified' && visibleUsers.length === 0) {
 | 
			
		||||
		if (typeof value === 'string') {	// 入力がstringならばresolverでGETが発生している
 | 
			
		||||
			// こちらから匿名GET出来たものならばpublic
 | 
			
		||||
			visibility = 'public';
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	let isTalk = note._misskey_talk && visibility === 'specified';
 | 
			
		||||
 | 
			
		||||
	const apMentions = await extractApMentions(note.tag);
 | 
			
		||||
	const apHashtags = await extractApHashtags(note.tag);
 | 
			
		||||
 | 
			
		||||
	// 添付ファイル
 | 
			
		||||
	// TODO: attachmentは必ずしもImageではない
 | 
			
		||||
	// TODO: attachmentは必ずしも配列ではない
 | 
			
		||||
	// Noteがsensitiveなら添付もsensitiveにする
 | 
			
		||||
	const limit = promiseLimit(2);
 | 
			
		||||
 | 
			
		||||
	note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : [];
 | 
			
		||||
	const files = note.attachment
 | 
			
		||||
		.map(attach => attach.sensitive = note.sensitive)
 | 
			
		||||
		? (await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x)) as Promise<DriveFile>)))
 | 
			
		||||
			.filter(image => image != null)
 | 
			
		||||
		: [];
 | 
			
		||||
 | 
			
		||||
	// リプライ
 | 
			
		||||
	const reply: Note | null = note.inReplyTo
 | 
			
		||||
		? await resolveNote(note.inReplyTo, resolver).then(x => {
 | 
			
		||||
			if (x == null) {
 | 
			
		||||
				logger.warn(`Specified inReplyTo, but nout found`);
 | 
			
		||||
				throw new Error('inReplyTo not found');
 | 
			
		||||
			} else {
 | 
			
		||||
				return x;
 | 
			
		||||
			}
 | 
			
		||||
		}).catch(async e => {
 | 
			
		||||
			// トークだったらinReplyToのエラーは無視
 | 
			
		||||
			const uri = getApId(note.inReplyTo);
 | 
			
		||||
			if (uri.startsWith(config.url + '/')) {
 | 
			
		||||
				const id = uri.split('/').pop();
 | 
			
		||||
				const talk = await MessagingMessages.findOne(id);
 | 
			
		||||
				if (talk) {
 | 
			
		||||
					isTalk = true;
 | 
			
		||||
					return null;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${e.statusCode || e}`);
 | 
			
		||||
			throw e;
 | 
			
		||||
		})
 | 
			
		||||
		: null;
 | 
			
		||||
 | 
			
		||||
	// 引用
 | 
			
		||||
	let quote: Note | undefined | null;
 | 
			
		||||
 | 
			
		||||
	if (note._misskey_quote || note.quoteUrl) {
 | 
			
		||||
		const tryResolveNote = async (uri: string): Promise<{
 | 
			
		||||
			status: 'ok';
 | 
			
		||||
			res: Note | null;
 | 
			
		||||
		} | {
 | 
			
		||||
			status: 'permerror' | 'temperror';
 | 
			
		||||
		}> => {
 | 
			
		||||
			if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' };
 | 
			
		||||
			try {
 | 
			
		||||
				const res = await resolveNote(uri);
 | 
			
		||||
				if (res) {
 | 
			
		||||
					return {
 | 
			
		||||
						status: 'ok',
 | 
			
		||||
						res
 | 
			
		||||
					};
 | 
			
		||||
				} else {
 | 
			
		||||
					return {
 | 
			
		||||
						status: 'permerror'
 | 
			
		||||
					};
 | 
			
		||||
				}
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				return {
 | 
			
		||||
					status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror'
 | 
			
		||||
				};
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string'));
 | 
			
		||||
		const results = await Promise.all(uris.map(uri => tryResolveNote(uri)));
 | 
			
		||||
 | 
			
		||||
		quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x);
 | 
			
		||||
		if (!quote) {
 | 
			
		||||
			if (results.some(x => x.status === 'temperror')) {
 | 
			
		||||
				throw 'quote resolve failed';
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const cw = note.summary === '' ? null : note.summary;
 | 
			
		||||
 | 
			
		||||
	// テキストのパース
 | 
			
		||||
	const text = note._misskey_content || (note.content ? htmlToMfm(note.content, note.tag) : null);
 | 
			
		||||
 | 
			
		||||
	// vote
 | 
			
		||||
	if (reply && reply.hasPoll) {
 | 
			
		||||
		const poll = await Polls.findOneOrFail(reply.id);
 | 
			
		||||
 | 
			
		||||
		const tryCreateVote = async (name: string, index: number): Promise<null> => {
 | 
			
		||||
			if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) {
 | 
			
		||||
				logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
 | 
			
		||||
			} else if (index >= 0) {
 | 
			
		||||
				logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
 | 
			
		||||
				await vote(actor, reply, index);
 | 
			
		||||
 | 
			
		||||
				// リモートフォロワーにUpdate配信
 | 
			
		||||
				deliverQuestionUpdate(reply.id);
 | 
			
		||||
			}
 | 
			
		||||
			return null;
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		if (note.name) {
 | 
			
		||||
			return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name));
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const emojis = await extractEmojis(note.tag || [], actor.host).catch(e => {
 | 
			
		||||
		logger.info(`extractEmojis: ${e}`);
 | 
			
		||||
		return [] as Emoji[];
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const apEmojis = emojis.map(emoji => emoji.name);
 | 
			
		||||
 | 
			
		||||
	const poll = await extractPollFromQuestion(note, resolver).catch(() => undefined);
 | 
			
		||||
 | 
			
		||||
	// ユーザーの情報が古かったらついでに更新しておく
 | 
			
		||||
	if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
 | 
			
		||||
		if (actor.uri) updatePerson(actor.uri);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (isTalk) {
 | 
			
		||||
		for (const recipient of visibleUsers) {
 | 
			
		||||
			await createMessage(actor, recipient, undefined, text || undefined, (files && files.length > 0) ? files[0] : null, object.id);
 | 
			
		||||
			return null;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return await post(actor, {
 | 
			
		||||
		createdAt: note.published ? new Date(note.published) : null,
 | 
			
		||||
		files,
 | 
			
		||||
		reply,
 | 
			
		||||
		renote: quote,
 | 
			
		||||
		name: note.name,
 | 
			
		||||
		cw,
 | 
			
		||||
		text,
 | 
			
		||||
		viaMobile: false,
 | 
			
		||||
		localOnly: false,
 | 
			
		||||
		visibility,
 | 
			
		||||
		visibleUsers,
 | 
			
		||||
		apMentions,
 | 
			
		||||
		apHashtags,
 | 
			
		||||
		apEmojis,
 | 
			
		||||
		poll,
 | 
			
		||||
		uri: note.id,
 | 
			
		||||
		url: getOneApHrefNullable(note.url),
 | 
			
		||||
	}, silent);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Noteを解決します。
 | 
			
		||||
 *
 | 
			
		||||
 * Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ
 | 
			
		||||
 * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
 | 
			
		||||
 */
 | 
			
		||||
export async function resolveNote(value: string | IObject, resolver?: Resolver): Promise<Note | null> {
 | 
			
		||||
	const uri = typeof value === 'string' ? value : value.id;
 | 
			
		||||
	if (uri == null) throw new Error('missing uri');
 | 
			
		||||
 | 
			
		||||
	// ブロックしてたら中断
 | 
			
		||||
	const meta = await fetchMeta();
 | 
			
		||||
	if (meta.blockedHosts.includes(extractDbHost(uri))) throw { statusCode: 451 };
 | 
			
		||||
 | 
			
		||||
	const unlock = await getApLock(uri);
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		//#region このサーバーに既に登録されていたらそれを返す
 | 
			
		||||
		const exist = await fetchNote(uri);
 | 
			
		||||
 | 
			
		||||
		if (exist) {
 | 
			
		||||
			return exist;
 | 
			
		||||
		}
 | 
			
		||||
		//#endregion
 | 
			
		||||
 | 
			
		||||
		if (uri.startsWith(config.url)) {
 | 
			
		||||
			throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// リモートサーバーからフェッチしてきて登録
 | 
			
		||||
		// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
 | 
			
		||||
		// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
 | 
			
		||||
		return await createNote(uri, resolver, true);
 | 
			
		||||
	} finally {
 | 
			
		||||
		unlock();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function extractEmojis(tags: IObject | IObject[], host: string): Promise<Emoji[]> {
 | 
			
		||||
	host = toPuny(host);
 | 
			
		||||
 | 
			
		||||
	if (!tags) return [];
 | 
			
		||||
 | 
			
		||||
	const eomjiTags = toArray(tags).filter(isEmoji);
 | 
			
		||||
 | 
			
		||||
	return await Promise.all(eomjiTags.map(async tag => {
 | 
			
		||||
		const name = tag.name!.replace(/^:/, '').replace(/:$/, '');
 | 
			
		||||
		tag.icon = toSingle(tag.icon);
 | 
			
		||||
 | 
			
		||||
		const exists = await Emojis.findOne({
 | 
			
		||||
			host,
 | 
			
		||||
			name
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (exists) {
 | 
			
		||||
			if ((tag.updated != null && exists.updatedAt == null)
 | 
			
		||||
				|| (tag.id != null && exists.uri == null)
 | 
			
		||||
				|| (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt)
 | 
			
		||||
				|| (tag.icon!.url !== exists.url)
 | 
			
		||||
			) {
 | 
			
		||||
				await Emojis.update({
 | 
			
		||||
					host,
 | 
			
		||||
					name,
 | 
			
		||||
				}, {
 | 
			
		||||
					uri: tag.id,
 | 
			
		||||
					url: tag.icon!.url,
 | 
			
		||||
					updatedAt: new Date(),
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				return await Emojis.findOne({
 | 
			
		||||
					host,
 | 
			
		||||
					name
 | 
			
		||||
				}) as Emoji;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return exists;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		logger.info(`register emoji host=${host}, name=${name}`);
 | 
			
		||||
 | 
			
		||||
		return await Emojis.save({
 | 
			
		||||
			id: genId(),
 | 
			
		||||
			host,
 | 
			
		||||
			name,
 | 
			
		||||
			uri: tag.id,
 | 
			
		||||
			url: tag.icon!.url,
 | 
			
		||||
			updatedAt: new Date(),
 | 
			
		||||
			aliases: []
 | 
			
		||||
		} as Partial<Emoji>);
 | 
			
		||||
	}));
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										494
									
								
								packages/backend/src/remote/activitypub/models/person.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										494
									
								
								packages/backend/src/remote/activitypub/models/person.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,494 @@
 | 
			
		|||
import { URL } from 'url';
 | 
			
		||||
import * as promiseLimit from 'promise-limit';
 | 
			
		||||
 | 
			
		||||
import $, { Context } from 'cafy';
 | 
			
		||||
import config from '@/config/index';
 | 
			
		||||
import Resolver from '../resolver';
 | 
			
		||||
import { resolveImage } from './image';
 | 
			
		||||
import { isCollectionOrOrderedCollection, isCollection, IActor, getApId, getOneApHrefNullable, IObject, isPropertyValue, IApPropertyValue, getApType, isActor } from '../type';
 | 
			
		||||
import { fromHtml } from '../../../mfm/from-html';
 | 
			
		||||
import { htmlToMfm } from '../misc/html-to-mfm';
 | 
			
		||||
import { resolveNote, extractEmojis } from './note';
 | 
			
		||||
import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc';
 | 
			
		||||
import { extractApHashtags } from './tag';
 | 
			
		||||
import { apLogger } from '../logger';
 | 
			
		||||
import { Note } from '@/models/entities/note';
 | 
			
		||||
import { updateUsertags } from '@/services/update-hashtag';
 | 
			
		||||
import { Users, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '@/models/index';
 | 
			
		||||
import { User, IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { Emoji } from '@/models/entities/emoji';
 | 
			
		||||
import { UserNotePining } from '@/models/entities/user-note-pining';
 | 
			
		||||
import { genId } from '@/misc/gen-id';
 | 
			
		||||
import { instanceChart, usersChart } from '@/services/chart/index';
 | 
			
		||||
import { UserPublickey } from '@/models/entities/user-publickey';
 | 
			
		||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error';
 | 
			
		||||
import { toPuny } from '@/misc/convert-host';
 | 
			
		||||
import { UserProfile } from '@/models/entities/user-profile';
 | 
			
		||||
import { getConnection } from 'typeorm';
 | 
			
		||||
import { toArray } from '@/prelude/array';
 | 
			
		||||
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata';
 | 
			
		||||
import { normalizeForSearch } from '@/misc/normalize-for-search';
 | 
			
		||||
import { truncate } from '@/misc/truncate';
 | 
			
		||||
import { StatusError } from '@/misc/fetch';
 | 
			
		||||
 | 
			
		||||
const logger = apLogger;
 | 
			
		||||
 | 
			
		||||
const nameLength = 128;
 | 
			
		||||
const summaryLength = 2048;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validate and convert to actor object
 | 
			
		||||
 * @param x Fetched object
 | 
			
		||||
 * @param uri Fetch target URI
 | 
			
		||||
 */
 | 
			
		||||
function validateActor(x: IObject, uri: string): IActor {
 | 
			
		||||
	const expectHost = toPuny(new URL(uri).hostname);
 | 
			
		||||
 | 
			
		||||
	if (x == null) {
 | 
			
		||||
		throw new Error('invalid Actor: object is null');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!isActor(x)) {
 | 
			
		||||
		throw new Error(`invalid Actor type '${x.type}'`);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const validate = (name: string, value: any, validater: Context) => {
 | 
			
		||||
		const e = validater.test(value);
 | 
			
		||||
		if (e) throw new Error(`invalid Actor: ${name} ${e.message}`);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	validate('id', x.id, $.str.min(1));
 | 
			
		||||
	validate('inbox', x.inbox, $.str.min(1));
 | 
			
		||||
	validate('preferredUsername', x.preferredUsername, $.str.min(1).max(128).match(/^\w([\w-.]*\w)?$/));
 | 
			
		||||
 | 
			
		||||
	// These fields are only informational, and some AP software allows these
 | 
			
		||||
	// fields to be very long. If they are too long, we cut them off. This way
 | 
			
		||||
	// we can at least see these users and their activities.
 | 
			
		||||
	validate('name', truncate(x.name, nameLength), $.optional.nullable.str);
 | 
			
		||||
	validate('summary', truncate(x.summary, summaryLength), $.optional.nullable.str);
 | 
			
		||||
 | 
			
		||||
	const idHost = toPuny(new URL(x.id!).hostname);
 | 
			
		||||
	if (idHost !== expectHost) {
 | 
			
		||||
		throw new Error('invalid Actor: id has different host');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (x.publicKey) {
 | 
			
		||||
		if (typeof x.publicKey.id !== 'string') {
 | 
			
		||||
			throw new Error('invalid Actor: publicKey.id is not a string');
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const publicKeyIdHost = toPuny(new URL(x.publicKey.id).hostname);
 | 
			
		||||
		if (publicKeyIdHost !== expectHost) {
 | 
			
		||||
			throw new Error('invalid Actor: publicKey.id has different host');
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return x;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Personをフェッチします。
 | 
			
		||||
 *
 | 
			
		||||
 * Misskeyに対象のPersonが登録されていればそれを返します。
 | 
			
		||||
 */
 | 
			
		||||
export async function fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> {
 | 
			
		||||
	if (typeof uri !== 'string') throw new Error('uri is not string');
 | 
			
		||||
 | 
			
		||||
	// URIがこのサーバーを指しているならデータベースからフェッチ
 | 
			
		||||
	if (uri.startsWith(config.url + '/')) {
 | 
			
		||||
		const id = uri.split('/').pop();
 | 
			
		||||
		return await Users.findOne(id).then(x => x || null);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//#region このサーバーに既に登録されていたらそれを返す
 | 
			
		||||
	const exist = await Users.findOne({ uri });
 | 
			
		||||
 | 
			
		||||
	if (exist) {
 | 
			
		||||
		return exist;
 | 
			
		||||
	}
 | 
			
		||||
	//#endregion
 | 
			
		||||
 | 
			
		||||
	return null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Personを作成します。
 | 
			
		||||
 */
 | 
			
		||||
export async function createPerson(uri: string, resolver?: Resolver): Promise<User> {
 | 
			
		||||
	if (typeof uri !== 'string') throw new Error('uri is not string');
 | 
			
		||||
 | 
			
		||||
	if (uri.startsWith(config.url)) {
 | 
			
		||||
		throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (resolver == null) resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const object = await resolver.resolve(uri) as any;
 | 
			
		||||
 | 
			
		||||
	const person = validateActor(object, uri);
 | 
			
		||||
 | 
			
		||||
	logger.info(`Creating the Person: ${person.id}`);
 | 
			
		||||
 | 
			
		||||
	const host = toPuny(new URL(object.id).hostname);
 | 
			
		||||
 | 
			
		||||
	const { fields } = analyzeAttachments(person.attachment || []);
 | 
			
		||||
 | 
			
		||||
	const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
 | 
			
		||||
 | 
			
		||||
	const isBot = getApType(object) === 'Service';
 | 
			
		||||
 | 
			
		||||
	const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
 | 
			
		||||
 | 
			
		||||
	// Create user
 | 
			
		||||
	let user: IRemoteUser;
 | 
			
		||||
	try {
 | 
			
		||||
		// Start transaction
 | 
			
		||||
		await getConnection().transaction(async transactionalEntityManager => {
 | 
			
		||||
			user = await transactionalEntityManager.save(new User({
 | 
			
		||||
				id: genId(),
 | 
			
		||||
				avatarId: null,
 | 
			
		||||
				bannerId: null,
 | 
			
		||||
				createdAt: new Date(),
 | 
			
		||||
				lastFetchedAt: new Date(),
 | 
			
		||||
				name: truncate(person.name, nameLength),
 | 
			
		||||
				isLocked: !!person.manuallyApprovesFollowers,
 | 
			
		||||
				isExplorable: !!person.discoverable,
 | 
			
		||||
				username: person.preferredUsername,
 | 
			
		||||
				usernameLower: person.preferredUsername!.toLowerCase(),
 | 
			
		||||
				host,
 | 
			
		||||
				inbox: person.inbox,
 | 
			
		||||
				sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined),
 | 
			
		||||
				followersUri: person.followers ? getApId(person.followers) : undefined,
 | 
			
		||||
				featured: person.featured ? getApId(person.featured) : undefined,
 | 
			
		||||
				uri: person.id,
 | 
			
		||||
				tags,
 | 
			
		||||
				isBot,
 | 
			
		||||
				isCat: (person as any).isCat === true
 | 
			
		||||
			})) as IRemoteUser;
 | 
			
		||||
 | 
			
		||||
			await transactionalEntityManager.save(new UserProfile({
 | 
			
		||||
				userId: user.id,
 | 
			
		||||
				description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
 | 
			
		||||
				url: getOneApHrefNullable(person.url),
 | 
			
		||||
				fields,
 | 
			
		||||
				birthday: bday ? bday[0] : null,
 | 
			
		||||
				location: person['vcard:Address'] || null,
 | 
			
		||||
				userHost: host
 | 
			
		||||
			}));
 | 
			
		||||
 | 
			
		||||
			if (person.publicKey) {
 | 
			
		||||
				await transactionalEntityManager.save(new UserPublickey({
 | 
			
		||||
					userId: user.id,
 | 
			
		||||
					keyId: person.publicKey.id,
 | 
			
		||||
					keyPem: person.publicKey.publicKeyPem
 | 
			
		||||
				}));
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
	} catch (e) {
 | 
			
		||||
		// duplicate key error
 | 
			
		||||
		if (isDuplicateKeyValueError(e)) {
 | 
			
		||||
			// /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応
 | 
			
		||||
			const u = await Users.findOne({
 | 
			
		||||
				uri: person.id
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			if (u) {
 | 
			
		||||
				user = u as IRemoteUser;
 | 
			
		||||
			} else {
 | 
			
		||||
				throw new Error('already registered');
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			logger.error(e);
 | 
			
		||||
			throw e;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Register host
 | 
			
		||||
	registerOrFetchInstanceDoc(host).then(i => {
 | 
			
		||||
		Instances.increment({ id: i.id }, 'usersCount', 1);
 | 
			
		||||
		instanceChart.newUser(i.host);
 | 
			
		||||
		fetchInstanceMetadata(i);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	usersChart.update(user!, true);
 | 
			
		||||
 | 
			
		||||
	// ハッシュタグ更新
 | 
			
		||||
	updateUsertags(user!, tags);
 | 
			
		||||
 | 
			
		||||
	//#region アバターとヘッダー画像をフェッチ
 | 
			
		||||
	const [avatar, banner] = await Promise.all([
 | 
			
		||||
		person.icon,
 | 
			
		||||
		person.image
 | 
			
		||||
	].map(img =>
 | 
			
		||||
		img == null
 | 
			
		||||
			? Promise.resolve(null)
 | 
			
		||||
			: resolveImage(user!, img).catch(() => null)
 | 
			
		||||
	));
 | 
			
		||||
 | 
			
		||||
	const avatarId = avatar ? avatar.id : null;
 | 
			
		||||
	const bannerId = banner ? banner.id : null;
 | 
			
		||||
	const avatarUrl = avatar ? DriveFiles.getPublicUrl(avatar, true) : null;
 | 
			
		||||
	const bannerUrl = banner ? DriveFiles.getPublicUrl(banner) : null;
 | 
			
		||||
	const avatarBlurhash = avatar ? avatar.blurhash : null;
 | 
			
		||||
	const bannerBlurhash = banner ? banner.blurhash : null;
 | 
			
		||||
 | 
			
		||||
	await Users.update(user!.id, {
 | 
			
		||||
		avatarId,
 | 
			
		||||
		bannerId,
 | 
			
		||||
		avatarUrl,
 | 
			
		||||
		bannerUrl,
 | 
			
		||||
		avatarBlurhash,
 | 
			
		||||
		bannerBlurhash
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	user!.avatarId = avatarId;
 | 
			
		||||
	user!.bannerId = bannerId;
 | 
			
		||||
	user!.avatarUrl = avatarUrl;
 | 
			
		||||
	user!.bannerUrl = bannerUrl;
 | 
			
		||||
	user!.avatarBlurhash = avatarBlurhash;
 | 
			
		||||
	user!.bannerBlurhash = bannerBlurhash;
 | 
			
		||||
	//#endregion
 | 
			
		||||
 | 
			
		||||
	//#region カスタム絵文字取得
 | 
			
		||||
	const emojis = await extractEmojis(person.tag || [], host).catch(e => {
 | 
			
		||||
		logger.info(`extractEmojis: ${e}`);
 | 
			
		||||
		return [] as Emoji[];
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const emojiNames = emojis.map(emoji => emoji.name);
 | 
			
		||||
 | 
			
		||||
	await Users.update(user!.id, {
 | 
			
		||||
		emojis: emojiNames
 | 
			
		||||
	});
 | 
			
		||||
	//#endregion
 | 
			
		||||
 | 
			
		||||
	await updateFeatured(user!.id).catch(err => logger.error(err));
 | 
			
		||||
 | 
			
		||||
	return user!;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Personの情報を更新します。
 | 
			
		||||
 * Misskeyに対象のPersonが登録されていなければ無視します。
 | 
			
		||||
 * @param uri URI of Person
 | 
			
		||||
 * @param resolver Resolver
 | 
			
		||||
 * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します)
 | 
			
		||||
 */
 | 
			
		||||
export async function updatePerson(uri: string, resolver?: Resolver | null, hint?: object): Promise<void> {
 | 
			
		||||
	if (typeof uri !== 'string') throw new Error('uri is not string');
 | 
			
		||||
 | 
			
		||||
	// URIがこのサーバーを指しているならスキップ
 | 
			
		||||
	if (uri.startsWith(config.url + '/')) {
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//#region このサーバーに既に登録されているか
 | 
			
		||||
	const exist = await Users.findOne({ uri }) as IRemoteUser;
 | 
			
		||||
 | 
			
		||||
	if (exist == null) {
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	//#endregion
 | 
			
		||||
 | 
			
		||||
	if (resolver == null) resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const object = hint || await resolver.resolve(uri) as any;
 | 
			
		||||
 | 
			
		||||
	const person = validateActor(object, uri);
 | 
			
		||||
 | 
			
		||||
	logger.info(`Updating the Person: ${person.id}`);
 | 
			
		||||
 | 
			
		||||
	// アバターとヘッダー画像をフェッチ
 | 
			
		||||
	const [avatar, banner] = await Promise.all([
 | 
			
		||||
		person.icon,
 | 
			
		||||
		person.image
 | 
			
		||||
	].map(img =>
 | 
			
		||||
		img == null
 | 
			
		||||
			? Promise.resolve(null)
 | 
			
		||||
			: resolveImage(exist, img).catch(() => null)
 | 
			
		||||
	));
 | 
			
		||||
 | 
			
		||||
	// カスタム絵文字取得
 | 
			
		||||
	const emojis = await extractEmojis(person.tag || [], exist.host).catch(e => {
 | 
			
		||||
		logger.info(`extractEmojis: ${e}`);
 | 
			
		||||
		return [] as Emoji[];
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const emojiNames = emojis.map(emoji => emoji.name);
 | 
			
		||||
 | 
			
		||||
	const { fields } = analyzeAttachments(person.attachment || []);
 | 
			
		||||
 | 
			
		||||
	const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
 | 
			
		||||
 | 
			
		||||
	const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
 | 
			
		||||
 | 
			
		||||
	const updates = {
 | 
			
		||||
		lastFetchedAt: new Date(),
 | 
			
		||||
		inbox: person.inbox,
 | 
			
		||||
		sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined),
 | 
			
		||||
		followersUri: person.followers ? getApId(person.followers) : undefined,
 | 
			
		||||
		featured: person.featured,
 | 
			
		||||
		emojis: emojiNames,
 | 
			
		||||
		name: truncate(person.name, nameLength),
 | 
			
		||||
		tags,
 | 
			
		||||
		isBot: getApType(object) === 'Service',
 | 
			
		||||
		isCat: (person as any).isCat === true,
 | 
			
		||||
		isLocked: !!person.manuallyApprovesFollowers,
 | 
			
		||||
		isExplorable: !!person.discoverable,
 | 
			
		||||
	} as Partial<User>;
 | 
			
		||||
 | 
			
		||||
	if (avatar) {
 | 
			
		||||
		updates.avatarId = avatar.id;
 | 
			
		||||
		updates.avatarUrl = DriveFiles.getPublicUrl(avatar, true);
 | 
			
		||||
		updates.avatarBlurhash = avatar.blurhash;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (banner) {
 | 
			
		||||
		updates.bannerId = banner.id;
 | 
			
		||||
		updates.bannerUrl = DriveFiles.getPublicUrl(banner);
 | 
			
		||||
		updates.bannerBlurhash = banner.blurhash;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update user
 | 
			
		||||
	await Users.update(exist.id, updates);
 | 
			
		||||
 | 
			
		||||
	if (person.publicKey) {
 | 
			
		||||
		await UserPublickeys.update({ userId: exist.id }, {
 | 
			
		||||
			keyId: person.publicKey.id,
 | 
			
		||||
			keyPem: person.publicKey.publicKeyPem
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	await UserProfiles.update({ userId: exist.id }, {
 | 
			
		||||
		url: getOneApHrefNullable(person.url),
 | 
			
		||||
		fields,
 | 
			
		||||
		description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
 | 
			
		||||
		birthday: bday ? bday[0] : null,
 | 
			
		||||
		location: person['vcard:Address'] || null,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// ハッシュタグ更新
 | 
			
		||||
	updateUsertags(exist, tags);
 | 
			
		||||
 | 
			
		||||
	// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
 | 
			
		||||
	await Followings.update({
 | 
			
		||||
		followerId: exist.id
 | 
			
		||||
	}, {
 | 
			
		||||
		followerSharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined)
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	await updateFeatured(exist.id).catch(err => logger.error(err));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Personを解決します。
 | 
			
		||||
 *
 | 
			
		||||
 * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ
 | 
			
		||||
 * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
 | 
			
		||||
 */
 | 
			
		||||
export async function resolvePerson(uri: string, resolver?: Resolver): Promise<User> {
 | 
			
		||||
	if (typeof uri !== 'string') throw new Error('uri is not string');
 | 
			
		||||
 | 
			
		||||
	//#region このサーバーに既に登録されていたらそれを返す
 | 
			
		||||
	const exist = await fetchPerson(uri);
 | 
			
		||||
 | 
			
		||||
	if (exist) {
 | 
			
		||||
		return exist;
 | 
			
		||||
	}
 | 
			
		||||
	//#endregion
 | 
			
		||||
 | 
			
		||||
	// リモートサーバーからフェッチしてきて登録
 | 
			
		||||
	if (resolver == null) resolver = new Resolver();
 | 
			
		||||
	return await createPerson(uri, resolver);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const services: {
 | 
			
		||||
		[x: string]: (id: string, username: string) => any
 | 
			
		||||
	} = {
 | 
			
		||||
	'misskey:authentication:twitter': (userId, screenName) => ({ userId, screenName }),
 | 
			
		||||
	'misskey:authentication:github': (id, login) => ({ id, login }),
 | 
			
		||||
	'misskey:authentication:discord': (id, name) => $discord(id, name)
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const $discord = (id: string, name: string) => {
 | 
			
		||||
	if (typeof name !== 'string')
 | 
			
		||||
		name = 'unknown#0000';
 | 
			
		||||
	const [username, discriminator] = name.split('#');
 | 
			
		||||
	return { id, username, discriminator };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function addService(target: { [x: string]: any }, source: IApPropertyValue) {
 | 
			
		||||
	const service = services[source.name];
 | 
			
		||||
 | 
			
		||||
	if (typeof source.value !== 'string')
 | 
			
		||||
		source.value = 'unknown';
 | 
			
		||||
 | 
			
		||||
	const [id, username] = source.value.split('@');
 | 
			
		||||
 | 
			
		||||
	if (service)
 | 
			
		||||
		target[source.name.split(':')[2]] = service(id, username);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function analyzeAttachments(attachments: IObject | IObject[] | undefined) {
 | 
			
		||||
	const fields: {
 | 
			
		||||
		name: string,
 | 
			
		||||
		value: string
 | 
			
		||||
	}[] = [];
 | 
			
		||||
	const services: { [x: string]: any } = {};
 | 
			
		||||
 | 
			
		||||
	if (Array.isArray(attachments)) {
 | 
			
		||||
		for (const attachment of attachments.filter(isPropertyValue)) {
 | 
			
		||||
			if (isPropertyValue(attachment.identifier)) {
 | 
			
		||||
				addService(services, attachment.identifier);
 | 
			
		||||
			} else {
 | 
			
		||||
				fields.push({
 | 
			
		||||
					name: attachment.name,
 | 
			
		||||
					value: fromHtml(attachment.value)
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return { fields, services };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function updateFeatured(userId: User['id']) {
 | 
			
		||||
	const user = await Users.findOneOrFail(userId);
 | 
			
		||||
	if (!Users.isRemoteUser(user)) return;
 | 
			
		||||
	if (!user.featured) return;
 | 
			
		||||
 | 
			
		||||
	logger.info(`Updating the featured: ${user.uri}`);
 | 
			
		||||
 | 
			
		||||
	const resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	// Resolve to (Ordered)Collection Object
 | 
			
		||||
	const collection = await resolver.resolveCollection(user.featured);
 | 
			
		||||
	if (!isCollectionOrOrderedCollection(collection)) throw new Error(`Object is not Collection or OrderedCollection`);
 | 
			
		||||
 | 
			
		||||
	// Resolve to Object(may be Note) arrays
 | 
			
		||||
	const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
 | 
			
		||||
	const items = await Promise.all(toArray(unresolvedItems).map(x => resolver.resolve(x)));
 | 
			
		||||
 | 
			
		||||
	// Resolve and regist Notes
 | 
			
		||||
	const limit = promiseLimit<Note | null>(2);
 | 
			
		||||
	const featuredNotes = await Promise.all(items
 | 
			
		||||
		.filter(item => getApType(item) === 'Note')	// TODO: Noteでなくてもいいかも
 | 
			
		||||
		.slice(0, 5)
 | 
			
		||||
		.map(item => limit(() => resolveNote(item, resolver))));
 | 
			
		||||
 | 
			
		||||
	await getConnection().transaction(async transactionalEntityManager => {
 | 
			
		||||
		await transactionalEntityManager.delete(UserNotePining, { userId: user.id });
 | 
			
		||||
 | 
			
		||||
		// とりあえずidを別の時間で生成して順番を維持
 | 
			
		||||
		let td = 0;
 | 
			
		||||
		for (const note of featuredNotes.filter(note => note != null)) {
 | 
			
		||||
			td -= 1000;
 | 
			
		||||
			transactionalEntityManager.insert(UserNotePining, {
 | 
			
		||||
				id: genId(new Date(Date.now() + td)),
 | 
			
		||||
				createdAt: new Date(),
 | 
			
		||||
				userId: user.id,
 | 
			
		||||
				noteId: note!.id
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										83
									
								
								packages/backend/src/remote/activitypub/models/question.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								packages/backend/src/remote/activitypub/models/question.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,83 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import Resolver from '../resolver';
 | 
			
		||||
import { IObject, IQuestion, isQuestion,  } from '../type';
 | 
			
		||||
import { apLogger } from '../logger';
 | 
			
		||||
import { Notes, Polls } from '@/models/index';
 | 
			
		||||
import { IPoll } from '@/models/entities/poll';
 | 
			
		||||
 | 
			
		||||
export async function extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> {
 | 
			
		||||
	if (resolver == null) resolver = new Resolver();
 | 
			
		||||
 | 
			
		||||
	const question = await resolver.resolve(source);
 | 
			
		||||
 | 
			
		||||
	if (!isQuestion(question)) {
 | 
			
		||||
		throw new Error('invalid type');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const multiple = !question.oneOf;
 | 
			
		||||
	const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null;
 | 
			
		||||
 | 
			
		||||
	if (multiple && !question.anyOf) {
 | 
			
		||||
		throw new Error('invalid question');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const choices = question[multiple ? 'anyOf' : 'oneOf']!
 | 
			
		||||
		.map((x, i) => x.name!);
 | 
			
		||||
 | 
			
		||||
	const votes = question[multiple ? 'anyOf' : 'oneOf']!
 | 
			
		||||
		.map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0);
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		choices,
 | 
			
		||||
		votes,
 | 
			
		||||
		multiple,
 | 
			
		||||
		expiresAt
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Update votes of Question
 | 
			
		||||
 * @param uri URI of AP Question object
 | 
			
		||||
 * @returns true if updated
 | 
			
		||||
 */
 | 
			
		||||
export async function updateQuestion(value: any) {
 | 
			
		||||
	const uri = typeof value === 'string' ? value : value.id;
 | 
			
		||||
 | 
			
		||||
	// URIがこのサーバーを指しているならスキップ
 | 
			
		||||
	if (uri.startsWith(config.url + '/')) throw new Error('uri points local');
 | 
			
		||||
 | 
			
		||||
	//#region このサーバーに既に登録されているか
 | 
			
		||||
	const note = await Notes.findOne({ uri });
 | 
			
		||||
	if (note == null) throw new Error('Question is not registed');
 | 
			
		||||
 | 
			
		||||
	const poll = await Polls.findOne({ noteId: note.id });
 | 
			
		||||
	if (poll == null) throw new Error('Question is not registed');
 | 
			
		||||
	//#endregion
 | 
			
		||||
 | 
			
		||||
	// resolve new Question object
 | 
			
		||||
	const resolver = new Resolver();
 | 
			
		||||
	const question = await resolver.resolve(value) as IQuestion;
 | 
			
		||||
	apLogger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
 | 
			
		||||
 | 
			
		||||
	if (question.type !== 'Question') throw new Error('object is not a Question');
 | 
			
		||||
 | 
			
		||||
	const apChoices = question.oneOf || question.anyOf;
 | 
			
		||||
 | 
			
		||||
	let changed = false;
 | 
			
		||||
 | 
			
		||||
	for (const choice of poll.choices) {
 | 
			
		||||
		const oldCount = poll.votes[poll.choices.indexOf(choice)];
 | 
			
		||||
		const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems;
 | 
			
		||||
 | 
			
		||||
		if (oldCount != newCount) {
 | 
			
		||||
			changed = true;
 | 
			
		||||
			poll.votes[poll.choices.indexOf(choice)] = newCount;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	await Polls.update({ noteId: note.id }, {
 | 
			
		||||
		votes: poll.votes
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	return changed;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								packages/backend/src/remote/activitypub/models/tag.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								packages/backend/src/remote/activitypub/models/tag.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
import { toArray } from '@/prelude/array';
 | 
			
		||||
import { IObject, isHashtag, IApHashtag } from '../type';
 | 
			
		||||
 | 
			
		||||
export function extractApHashtags(tags: IObject | IObject[] | null | undefined) {
 | 
			
		||||
	if (tags == null) return [];
 | 
			
		||||
 | 
			
		||||
	const hashtags = extractApHashtagObjects(tags);
 | 
			
		||||
 | 
			
		||||
	return hashtags.map(tag => {
 | 
			
		||||
		const m = tag.name.match(/^#(.+)/);
 | 
			
		||||
		return m ? m[1] : null;
 | 
			
		||||
	}).filter((x): x is string => x != null);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] {
 | 
			
		||||
	if (tags == null) return [];
 | 
			
		||||
	return toArray(tags).filter(isHashtag);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								packages/backend/src/remote/activitypub/perform.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/backend/src/remote/activitypub/perform.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
import { IObject } from './type';
 | 
			
		||||
import { IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { performActivity } from './kernel/index';
 | 
			
		||||
 | 
			
		||||
export default async (actor: IRemoteUser, activity: IObject): Promise<void> => {
 | 
			
		||||
	await performActivity(actor, activity);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export default (object: any, user: { id: User['id']; host: null }) => ({
 | 
			
		||||
	type: 'Accept',
 | 
			
		||||
	actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
	object
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										9
									
								
								packages/backend/src/remote/activitypub/renderer/add.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								packages/backend/src/remote/activitypub/renderer/add.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { ILocalUser } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export default (user: ILocalUser, target: any, object: any) => ({
 | 
			
		||||
	type: 'Add',
 | 
			
		||||
	actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
	target,
 | 
			
		||||
	object
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										29
									
								
								packages/backend/src/remote/activitypub/renderer/announce.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								packages/backend/src/remote/activitypub/renderer/announce.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { Note } from '@/models/entities/note';
 | 
			
		||||
 | 
			
		||||
export default (object: any, note: Note) => {
 | 
			
		||||
	const attributedTo = `${config.url}/users/${note.userId}`;
 | 
			
		||||
 | 
			
		||||
	let to: string[] = [];
 | 
			
		||||
	let cc: string[] = [];
 | 
			
		||||
 | 
			
		||||
	if (note.visibility === 'public') {
 | 
			
		||||
		to = ['https://www.w3.org/ns/activitystreams#Public'];
 | 
			
		||||
		cc = [`${attributedTo}/followers`];
 | 
			
		||||
	} else if (note.visibility === 'home') {
 | 
			
		||||
		to = [`${attributedTo}/followers`];
 | 
			
		||||
		cc = ['https://www.w3.org/ns/activitystreams#Public'];
 | 
			
		||||
	} else {
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		id: `${config.url}/notes/${note.id}/activity`,
 | 
			
		||||
		actor: `${config.url}/users/${note.userId}`,
 | 
			
		||||
		type: 'Announce',
 | 
			
		||||
		published: note.createdAt.toISOString(),
 | 
			
		||||
		to,
 | 
			
		||||
		cc,
 | 
			
		||||
		object
 | 
			
		||||
	};
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { ILocalUser, IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export default (blocker: ILocalUser, blockee: IRemoteUser) => ({
 | 
			
		||||
	type: 'Block',
 | 
			
		||||
	actor: `${config.url}/users/${blocker.id}`,
 | 
			
		||||
	object: blockee.uri
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										17
									
								
								packages/backend/src/remote/activitypub/renderer/create.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								packages/backend/src/remote/activitypub/renderer/create.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { Note } from '@/models/entities/note';
 | 
			
		||||
 | 
			
		||||
export default (object: any, note: Note) => {
 | 
			
		||||
	const activity = {
 | 
			
		||||
		id: `${config.url}/notes/${note.id}/activity`,
 | 
			
		||||
		actor: `${config.url}/users/${note.userId}`,
 | 
			
		||||
		type: 'Create',
 | 
			
		||||
		published: note.createdAt.toISOString(),
 | 
			
		||||
		object
 | 
			
		||||
	} as any;
 | 
			
		||||
 | 
			
		||||
	if (object.to) activity.to = object.to;
 | 
			
		||||
	if (object.cc) activity.cc = object.cc;
 | 
			
		||||
 | 
			
		||||
	return activity;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export default (object: any, user: { id: User['id']; host: null }) => ({
 | 
			
		||||
	type: 'Delete',
 | 
			
		||||
	actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
	object,
 | 
			
		||||
	published: new Date().toISOString(),
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import { DriveFile } from '@/models/entities/drive-file';
 | 
			
		||||
import { DriveFiles } from '@/models/index';
 | 
			
		||||
 | 
			
		||||
export default (file: DriveFile) => ({
 | 
			
		||||
	type: 'Document',
 | 
			
		||||
	mediaType: file.type,
 | 
			
		||||
	url: DriveFiles.getPublicUrl(file),
 | 
			
		||||
	name: file.comment,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										14
									
								
								packages/backend/src/remote/activitypub/renderer/emoji.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/backend/src/remote/activitypub/renderer/emoji.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { Emoji } from '@/models/entities/emoji';
 | 
			
		||||
 | 
			
		||||
export default (emoji: Emoji) => ({
 | 
			
		||||
	id: `${config.url}/emojis/${emoji.name}`,
 | 
			
		||||
	type: 'Emoji',
 | 
			
		||||
	name: `:${emoji.name}:`,
 | 
			
		||||
	updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString,
 | 
			
		||||
	icon: {
 | 
			
		||||
		type: 'Image',
 | 
			
		||||
		mediaType: emoji.type || 'image/png',
 | 
			
		||||
		url: emoji.url
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { Relay } from '@/models/entities/relay';
 | 
			
		||||
import { ILocalUser } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export function renderFollowRelay(relay: Relay, relayActor: ILocalUser) {
 | 
			
		||||
	const follow = {
 | 
			
		||||
		id: `${config.url}/activities/follow-relay/${relay.id}`,
 | 
			
		||||
		type: 'Follow',
 | 
			
		||||
		actor: `${config.url}/users/${relayActor.id}`,
 | 
			
		||||
		object: 'https://www.w3.org/ns/activitystreams#Public'
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return follow;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { Users } from '@/models/index';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Convert (local|remote)(Follower|Followee)ID to URL
 | 
			
		||||
 * @param id Follower|Followee ID
 | 
			
		||||
 */
 | 
			
		||||
export default async function renderFollowUser(id: User['id']): Promise<any> {
 | 
			
		||||
	const user = await Users.findOneOrFail(id);
 | 
			
		||||
	return Users.isLocalUser(user) ? `${config.url}/users/${user.id}` : user.uri;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								packages/backend/src/remote/activitypub/renderer/follow.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/backend/src/remote/activitypub/renderer/follow.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
import { Users } from '@/models/index';
 | 
			
		||||
 | 
			
		||||
export default (follower: { id: User['id']; host: User['host']; uri: User['host'] }, followee: { id: User['id']; host: User['host']; uri: User['host'] }, requestId?: string) => {
 | 
			
		||||
	const follow = {
 | 
			
		||||
		type: 'Follow',
 | 
			
		||||
		actor: Users.isLocalUser(follower) ? `${config.url}/users/${follower.id}` : follower.uri,
 | 
			
		||||
		object: Users.isLocalUser(followee) ? `${config.url}/users/${followee.id}` : followee.uri
 | 
			
		||||
	} as any;
 | 
			
		||||
 | 
			
		||||
	if (requestId) follow.id = requestId;
 | 
			
		||||
 | 
			
		||||
	return follow;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
 | 
			
		||||
export default (tag: string) => ({
 | 
			
		||||
	type: 'Hashtag',
 | 
			
		||||
	href: `${config.url}/tags/${encodeURIComponent(tag)}`,
 | 
			
		||||
	name: `#${tag}`
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import { DriveFile } from '@/models/entities/drive-file';
 | 
			
		||||
import { DriveFiles } from '@/models/index';
 | 
			
		||||
 | 
			
		||||
export default (file: DriveFile) => ({
 | 
			
		||||
	type: 'Image',
 | 
			
		||||
	url: DriveFiles.getPublicUrl(file),
 | 
			
		||||
	sensitive: file.isSensitive,
 | 
			
		||||
	name: file.comment
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										59
									
								
								packages/backend/src/remote/activitypub/renderer/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								packages/backend/src/remote/activitypub/renderer/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,59 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { v4 as uuid } from 'uuid';
 | 
			
		||||
import { IActivity } from '../type';
 | 
			
		||||
import { LdSignature } from '../misc/ld-signature';
 | 
			
		||||
import { getUserKeypair } from '@/misc/keypair-store';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export const renderActivity = (x: any): IActivity | null => {
 | 
			
		||||
	if (x == null) return null;
 | 
			
		||||
 | 
			
		||||
	if (x !== null && typeof x === 'object' && x.id == null) {
 | 
			
		||||
		x.id = `${config.url}/${uuid()}`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return Object.assign({
 | 
			
		||||
		'@context': [
 | 
			
		||||
			'https://www.w3.org/ns/activitystreams',
 | 
			
		||||
			'https://w3id.org/security/v1',
 | 
			
		||||
			{
 | 
			
		||||
				// as non-standards
 | 
			
		||||
				manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
 | 
			
		||||
				sensitive: 'as:sensitive',
 | 
			
		||||
				Hashtag: 'as:Hashtag',
 | 
			
		||||
				quoteUrl: 'as:quoteUrl',
 | 
			
		||||
				// Mastodon
 | 
			
		||||
				toot: 'http://joinmastodon.org/ns#',
 | 
			
		||||
				Emoji: 'toot:Emoji',
 | 
			
		||||
				featured: 'toot:featured',
 | 
			
		||||
				discoverable: 'toot:discoverable',
 | 
			
		||||
				// schema
 | 
			
		||||
				schema: 'http://schema.org#',
 | 
			
		||||
				PropertyValue: 'schema:PropertyValue',
 | 
			
		||||
				value: 'schema:value',
 | 
			
		||||
				// Misskey
 | 
			
		||||
				misskey: `${config.url}/ns#`,
 | 
			
		||||
				'_misskey_content': 'misskey:_misskey_content',
 | 
			
		||||
				'_misskey_quote': 'misskey:_misskey_quote',
 | 
			
		||||
				'_misskey_reaction': 'misskey:_misskey_reaction',
 | 
			
		||||
				'_misskey_votes': 'misskey:_misskey_votes',
 | 
			
		||||
				'_misskey_talk': 'misskey:_misskey_talk',
 | 
			
		||||
				'isCat': 'misskey:isCat',
 | 
			
		||||
				// vcard
 | 
			
		||||
				vcard: 'http://www.w3.org/2006/vcard/ns#',
 | 
			
		||||
			}
 | 
			
		||||
		]
 | 
			
		||||
	}, x);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const attachLdSignature = async (activity: any, user: { id: User['id']; host: null; }): Promise<IActivity | null> => {
 | 
			
		||||
	if (activity == null) return null;
 | 
			
		||||
 | 
			
		||||
	const keypair = await getUserKeypair(user.id);
 | 
			
		||||
 | 
			
		||||
	const ldSignature = new LdSignature();
 | 
			
		||||
	ldSignature.debug = false;
 | 
			
		||||
	activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${config.url}/users/${user.id}#main-key`);
 | 
			
		||||
 | 
			
		||||
	return activity;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										14
									
								
								packages/backend/src/remote/activitypub/renderer/key.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/backend/src/remote/activitypub/renderer/key.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { ILocalUser } from '@/models/entities/user';
 | 
			
		||||
import { UserKeypair } from '@/models/entities/user-keypair';
 | 
			
		||||
import { createPublicKey } from 'crypto';
 | 
			
		||||
 | 
			
		||||
export default (user: ILocalUser, key: UserKeypair, postfix?: string) => ({
 | 
			
		||||
	id: `${config.url}/users/${user.id}${postfix || '/publickey'}`,
 | 
			
		||||
	type: 'Key',
 | 
			
		||||
	owner: `${config.url}/users/${user.id}`,
 | 
			
		||||
	publicKeyPem: createPublicKey(key.publicKey).export({
 | 
			
		||||
		type: 'spki',
 | 
			
		||||
		format: 'pem'
 | 
			
		||||
	})
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										30
									
								
								packages/backend/src/remote/activitypub/renderer/like.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								packages/backend/src/remote/activitypub/renderer/like.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { NoteReaction } from '@/models/entities/note-reaction';
 | 
			
		||||
import { Note } from '@/models/entities/note';
 | 
			
		||||
import { Emojis } from '@/models/index';
 | 
			
		||||
import renderEmoji from './emoji';
 | 
			
		||||
 | 
			
		||||
export const renderLike = async (noteReaction: NoteReaction, note: Note) => {
 | 
			
		||||
	const reaction = noteReaction.reaction;
 | 
			
		||||
 | 
			
		||||
	const object =  {
 | 
			
		||||
		type: 'Like',
 | 
			
		||||
		id: `${config.url}/likes/${noteReaction.id}`,
 | 
			
		||||
		actor: `${config.url}/users/${noteReaction.userId}`,
 | 
			
		||||
		object: note.uri ? note.uri : `${config.url}/notes/${noteReaction.noteId}`,
 | 
			
		||||
		content: reaction,
 | 
			
		||||
		_misskey_reaction: reaction
 | 
			
		||||
	} as any;
 | 
			
		||||
 | 
			
		||||
	if (reaction.startsWith(':')) {
 | 
			
		||||
		const name = reaction.replace(/:/g, '');
 | 
			
		||||
		const emoji = await Emojis.findOne({
 | 
			
		||||
			name,
 | 
			
		||||
			host: null
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (emoji) object.tag = [ renderEmoji(emoji) ];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return object;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { User, ILocalUser } from '@/models/entities/user';
 | 
			
		||||
import { Users } from '@/models/index';
 | 
			
		||||
 | 
			
		||||
export default (mention: User) => ({
 | 
			
		||||
	type: 'Mention',
 | 
			
		||||
	href: Users.isRemoteUser(mention) ? mention.uri : `${config.url}/users/${(mention as ILocalUser).id}`,
 | 
			
		||||
	name: Users.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as ILocalUser).username}`,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										168
									
								
								packages/backend/src/remote/activitypub/renderer/note.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								packages/backend/src/remote/activitypub/renderer/note.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,168 @@
 | 
			
		|||
import renderDocument from './document';
 | 
			
		||||
import renderHashtag from './hashtag';
 | 
			
		||||
import renderMention from './mention';
 | 
			
		||||
import renderEmoji from './emoji';
 | 
			
		||||
import config from '@/config/index';
 | 
			
		||||
import toHtml from '../misc/get-note-html';
 | 
			
		||||
import { Note, IMentionedRemoteUsers } from '@/models/entities/note';
 | 
			
		||||
import { DriveFile } from '@/models/entities/drive-file';
 | 
			
		||||
import { DriveFiles, Notes, Users, Emojis, Polls } from '@/models/index';
 | 
			
		||||
import { In } from 'typeorm';
 | 
			
		||||
import { Emoji } from '@/models/entities/emoji';
 | 
			
		||||
import { Poll } from '@/models/entities/poll';
 | 
			
		||||
 | 
			
		||||
export default async function renderNote(note: Note, dive = true, isTalk = false): Promise<any> {
 | 
			
		||||
	const getPromisedFiles = async (ids: string[]) => {
 | 
			
		||||
		if (!ids || ids.length === 0) return [];
 | 
			
		||||
		const items = await DriveFiles.find({ id: In(ids) });
 | 
			
		||||
		return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[];
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	let inReplyTo;
 | 
			
		||||
	let inReplyToNote: Note | undefined;
 | 
			
		||||
 | 
			
		||||
	if (note.replyId) {
 | 
			
		||||
		inReplyToNote = await Notes.findOne(note.replyId);
 | 
			
		||||
 | 
			
		||||
		if (inReplyToNote != null) {
 | 
			
		||||
			const inReplyToUser = await Users.findOne(inReplyToNote.userId);
 | 
			
		||||
 | 
			
		||||
			if (inReplyToUser != null) {
 | 
			
		||||
				if (inReplyToNote.uri) {
 | 
			
		||||
					inReplyTo = inReplyToNote.uri;
 | 
			
		||||
				} else {
 | 
			
		||||
					if (dive) {
 | 
			
		||||
						inReplyTo = await renderNote(inReplyToNote, false);
 | 
			
		||||
					} else {
 | 
			
		||||
						inReplyTo = `${config.url}/notes/${inReplyToNote.id}`;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		inReplyTo = null;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	let quote;
 | 
			
		||||
 | 
			
		||||
	if (note.renoteId) {
 | 
			
		||||
		const renote = await Notes.findOne(note.renoteId);
 | 
			
		||||
 | 
			
		||||
		if (renote) {
 | 
			
		||||
			quote = renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const user = await Users.findOneOrFail(note.userId);
 | 
			
		||||
 | 
			
		||||
	const attributedTo = `${config.url}/users/${user.id}`;
 | 
			
		||||
 | 
			
		||||
	const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
 | 
			
		||||
 | 
			
		||||
	let to: string[] = [];
 | 
			
		||||
	let cc: string[] = [];
 | 
			
		||||
 | 
			
		||||
	if (note.visibility === 'public') {
 | 
			
		||||
		to = ['https://www.w3.org/ns/activitystreams#Public'];
 | 
			
		||||
		cc = [`${attributedTo}/followers`].concat(mentions);
 | 
			
		||||
	} else if (note.visibility === 'home') {
 | 
			
		||||
		to = [`${attributedTo}/followers`];
 | 
			
		||||
		cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
 | 
			
		||||
	} else if (note.visibility === 'followers') {
 | 
			
		||||
		to = [`${attributedTo}/followers`];
 | 
			
		||||
		cc = mentions;
 | 
			
		||||
	} else {
 | 
			
		||||
		to = mentions;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const mentionedUsers = note.mentions.length > 0 ? await Users.find({
 | 
			
		||||
		id: In(note.mentions)
 | 
			
		||||
	}) : [];
 | 
			
		||||
 | 
			
		||||
	const hashtagTags = (note.tags || []).map(tag => renderHashtag(tag));
 | 
			
		||||
	const mentionTags = mentionedUsers.map(u => renderMention(u));
 | 
			
		||||
 | 
			
		||||
	const files = await getPromisedFiles(note.fileIds);
 | 
			
		||||
 | 
			
		||||
	const text = note.text;
 | 
			
		||||
	let poll: Poll | undefined;
 | 
			
		||||
 | 
			
		||||
	if (note.hasPoll) {
 | 
			
		||||
		poll = await Polls.findOne({ noteId: note.id });
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	let apText = text;
 | 
			
		||||
	if (apText == null) apText = '';
 | 
			
		||||
 | 
			
		||||
	if (quote) {
 | 
			
		||||
		apText += `\n\nRE: ${quote}`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
 | 
			
		||||
 | 
			
		||||
	const content = toHtml(Object.assign({}, note, {
 | 
			
		||||
		text: apText
 | 
			
		||||
	}));
 | 
			
		||||
 | 
			
		||||
	const emojis = await getEmojis(note.emojis);
 | 
			
		||||
	const apemojis = emojis.map(emoji => renderEmoji(emoji));
 | 
			
		||||
 | 
			
		||||
	const tag = [
 | 
			
		||||
		...hashtagTags,
 | 
			
		||||
		...mentionTags,
 | 
			
		||||
		...apemojis,
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
	const asPoll = poll ? {
 | 
			
		||||
		type: 'Question',
 | 
			
		||||
		content: toHtml(Object.assign({}, note, {
 | 
			
		||||
			text: text
 | 
			
		||||
		})),
 | 
			
		||||
		[poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
 | 
			
		||||
		[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
 | 
			
		||||
			type: 'Note',
 | 
			
		||||
			name: text,
 | 
			
		||||
			replies: {
 | 
			
		||||
				type: 'Collection',
 | 
			
		||||
				totalItems: poll!.votes[i]
 | 
			
		||||
			}
 | 
			
		||||
		}))
 | 
			
		||||
	} : {};
 | 
			
		||||
 | 
			
		||||
	const asTalk = isTalk ? {
 | 
			
		||||
		_misskey_talk: true
 | 
			
		||||
	} : {};
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		id: `${config.url}/notes/${note.id}`,
 | 
			
		||||
		type: 'Note',
 | 
			
		||||
		attributedTo,
 | 
			
		||||
		summary,
 | 
			
		||||
		content,
 | 
			
		||||
		_misskey_content: text,
 | 
			
		||||
		_misskey_quote: quote,
 | 
			
		||||
		quoteUrl: quote,
 | 
			
		||||
		published: note.createdAt.toISOString(),
 | 
			
		||||
		to,
 | 
			
		||||
		cc,
 | 
			
		||||
		inReplyTo,
 | 
			
		||||
		attachment: files.map(renderDocument),
 | 
			
		||||
		sensitive: note.cw != null || files.some(file => file.isSensitive),
 | 
			
		||||
		tag,
 | 
			
		||||
		...asPoll,
 | 
			
		||||
		...asTalk
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getEmojis(names: string[]): Promise<Emoji[]> {
 | 
			
		||||
	if (names == null || names.length === 0) return [];
 | 
			
		||||
 | 
			
		||||
	const emojis = await Promise.all(
 | 
			
		||||
		names.map(name => Emojis.findOne({
 | 
			
		||||
			name,
 | 
			
		||||
			host: null
 | 
			
		||||
		}))
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	return emojis.filter(emoji => emoji != null) as Emoji[];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
/**
 | 
			
		||||
 * Render OrderedCollectionPage
 | 
			
		||||
 * @param id URL of self
 | 
			
		||||
 * @param totalItems Number of total items
 | 
			
		||||
 * @param orderedItems Items
 | 
			
		||||
 * @param partOf URL of base
 | 
			
		||||
 * @param prev URL of prev page (optional)
 | 
			
		||||
 * @param next URL of next page (optional)
 | 
			
		||||
 */
 | 
			
		||||
export default function(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) {
 | 
			
		||||
	const page = {
 | 
			
		||||
		id,
 | 
			
		||||
		partOf,
 | 
			
		||||
		type: 'OrderedCollectionPage',
 | 
			
		||||
		totalItems,
 | 
			
		||||
		orderedItems
 | 
			
		||||
	} as any;
 | 
			
		||||
 | 
			
		||||
	if (prev) page.prev = prev;
 | 
			
		||||
	if (next) page.next = next;
 | 
			
		||||
 | 
			
		||||
	return page;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
/**
 | 
			
		||||
 * Render OrderedCollection
 | 
			
		||||
 * @param id URL of self
 | 
			
		||||
 * @param totalItems Total number of items
 | 
			
		||||
 * @param first URL of first page (optional)
 | 
			
		||||
 * @param last URL of last page (optional)
 | 
			
		||||
 * @param orderedItems attached objects (optional)
 | 
			
		||||
 */
 | 
			
		||||
export default function(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: object) {
 | 
			
		||||
	const page: any = {
 | 
			
		||||
		id,
 | 
			
		||||
		type: 'OrderedCollection',
 | 
			
		||||
		totalItems,
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (first) page.first = first;
 | 
			
		||||
	if (last) page.last = last;
 | 
			
		||||
	if (orderedItems) page.orderedItems = orderedItems;
 | 
			
		||||
 | 
			
		||||
	return page;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										89
									
								
								packages/backend/src/remote/activitypub/renderer/person.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								packages/backend/src/remote/activitypub/renderer/person.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
import { URL } from 'url';
 | 
			
		||||
import * as mfm from 'mfm-js';
 | 
			
		||||
import renderImage from './image';
 | 
			
		||||
import renderKey from './key';
 | 
			
		||||
import config from '@/config/index';
 | 
			
		||||
import { ILocalUser } from '@/models/entities/user';
 | 
			
		||||
import { toHtml } from '../../../mfm/to-html';
 | 
			
		||||
import { getEmojis } from './note';
 | 
			
		||||
import renderEmoji from './emoji';
 | 
			
		||||
import { IIdentifier } from '../models/identifier';
 | 
			
		||||
import renderHashtag from './hashtag';
 | 
			
		||||
import { DriveFiles, UserProfiles } from '@/models/index';
 | 
			
		||||
import { getUserKeypair } from '@/misc/keypair-store';
 | 
			
		||||
 | 
			
		||||
export async function renderPerson(user: ILocalUser) {
 | 
			
		||||
	const id = `${config.url}/users/${user.id}`;
 | 
			
		||||
	const isSystem = !!user.username.match(/\./);
 | 
			
		||||
 | 
			
		||||
	const [avatar, banner, profile] = await Promise.all([
 | 
			
		||||
		user.avatarId ? DriveFiles.findOne(user.avatarId) : Promise.resolve(undefined),
 | 
			
		||||
		user.bannerId ? DriveFiles.findOne(user.bannerId) : Promise.resolve(undefined),
 | 
			
		||||
		UserProfiles.findOneOrFail(user.id)
 | 
			
		||||
	]);
 | 
			
		||||
 | 
			
		||||
	const attachment: {
 | 
			
		||||
		type: 'PropertyValue',
 | 
			
		||||
		name: string,
 | 
			
		||||
		value: string,
 | 
			
		||||
		identifier?: IIdentifier
 | 
			
		||||
	}[] = [];
 | 
			
		||||
 | 
			
		||||
	if (profile.fields) {
 | 
			
		||||
		for (const field of profile.fields) {
 | 
			
		||||
			attachment.push({
 | 
			
		||||
				type: 'PropertyValue',
 | 
			
		||||
				name: field.name,
 | 
			
		||||
				value: (field.value != null && field.value.match(/^https?:/))
 | 
			
		||||
					? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>`
 | 
			
		||||
					: field.value
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const emojis = await getEmojis(user.emojis);
 | 
			
		||||
	const apemojis = emojis.map(emoji => renderEmoji(emoji));
 | 
			
		||||
 | 
			
		||||
	const hashtagTags = (user.tags || []).map(tag => renderHashtag(tag));
 | 
			
		||||
 | 
			
		||||
	const tag = [
 | 
			
		||||
		...apemojis,
 | 
			
		||||
		...hashtagTags,
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
	const keypair = await getUserKeypair(user.id);
 | 
			
		||||
 | 
			
		||||
	const person = {
 | 
			
		||||
		type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person',
 | 
			
		||||
		id,
 | 
			
		||||
		inbox: `${id}/inbox`,
 | 
			
		||||
		outbox: `${id}/outbox`,
 | 
			
		||||
		followers: `${id}/followers`,
 | 
			
		||||
		following: `${id}/following`,
 | 
			
		||||
		featured: `${id}/collections/featured`,
 | 
			
		||||
		sharedInbox: `${config.url}/inbox`,
 | 
			
		||||
		endpoints: { sharedInbox: `${config.url}/inbox` },
 | 
			
		||||
		url: `${config.url}/@${user.username}`,
 | 
			
		||||
		preferredUsername: user.username,
 | 
			
		||||
		name: user.name,
 | 
			
		||||
		summary: profile.description ? toHtml(mfm.parse(profile.description)) : null,
 | 
			
		||||
		icon: avatar ? renderImage(avatar) : null,
 | 
			
		||||
		image: banner ? renderImage(banner) : null,
 | 
			
		||||
		tag,
 | 
			
		||||
		manuallyApprovesFollowers: user.isLocked,
 | 
			
		||||
		discoverable: !!user.isExplorable,
 | 
			
		||||
		publicKey: renderKey(user, keypair, `#main-key`),
 | 
			
		||||
		isCat: user.isCat,
 | 
			
		||||
		attachment: attachment.length ? attachment : undefined
 | 
			
		||||
	} as any;
 | 
			
		||||
 | 
			
		||||
	if (profile?.birthday) {
 | 
			
		||||
		person['vcard:bday'] = profile.birthday;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (profile?.location) {
 | 
			
		||||
		person['vcard:Address'] = profile.location;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return person;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										23
									
								
								packages/backend/src/remote/activitypub/renderer/question.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/backend/src/remote/activitypub/renderer/question.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
import { Note } from '@/models/entities/note';
 | 
			
		||||
import { Poll } from '@/models/entities/poll';
 | 
			
		||||
 | 
			
		||||
export default async function renderQuestion(user: { id: User['id'] }, note: Note, poll: Poll) {
 | 
			
		||||
	const question = {
 | 
			
		||||
		type: 'Question',
 | 
			
		||||
		id: `${config.url}/questions/${note.id}`,
 | 
			
		||||
		actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
		content:  note.text || '',
 | 
			
		||||
		[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
 | 
			
		||||
			name: text,
 | 
			
		||||
			_misskey_votes: poll.votes[i],
 | 
			
		||||
			replies: {
 | 
			
		||||
				type: 'Collection',
 | 
			
		||||
				totalItems: poll.votes[i]
 | 
			
		||||
			}
 | 
			
		||||
		}))
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return question;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								packages/backend/src/remote/activitypub/renderer/read.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								packages/backend/src/remote/activitypub/renderer/read.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
import { MessagingMessage } from '@/models/entities/messaging-message';
 | 
			
		||||
 | 
			
		||||
export const renderReadActivity = (user: { id: User['id'] }, message: MessagingMessage) => ({
 | 
			
		||||
	type: 'Read',
 | 
			
		||||
	actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
	object: message.uri
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export default (object: any, user: { id: User['id'] }) => ({
 | 
			
		||||
	type: 'Reject',
 | 
			
		||||
	actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
	object
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export default (user: { id: User['id'] }, target: any, object: any) => ({
 | 
			
		||||
	type: 'Remove',
 | 
			
		||||
	actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
	target,
 | 
			
		||||
	object
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
export default (id: string) => ({
 | 
			
		||||
	id,
 | 
			
		||||
	type: 'Tombstone'
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										13
									
								
								packages/backend/src/remote/activitypub/renderer/undo.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								packages/backend/src/remote/activitypub/renderer/undo.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { ILocalUser, User } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export default (object: any, user: { id: User['id'] }) => {
 | 
			
		||||
	if (object == null) return null;
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		type: 'Undo',
 | 
			
		||||
		actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
		object,
 | 
			
		||||
		published: new Date().toISOString(),
 | 
			
		||||
	};
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										15
									
								
								packages/backend/src/remote/activitypub/renderer/update.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/backend/src/remote/activitypub/renderer/update.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
 | 
			
		||||
export default (object: any, user: { id: User['id'] }) => {
 | 
			
		||||
	const activity = {
 | 
			
		||||
		id: `${config.url}/users/${user.id}#updates/${new Date().getTime()}`,
 | 
			
		||||
		actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
		type: 'Update',
 | 
			
		||||
		to: [ 'https://www.w3.org/ns/activitystreams#Public' ],
 | 
			
		||||
		object,
 | 
			
		||||
		published: new Date().toISOString(),
 | 
			
		||||
	} as any;
 | 
			
		||||
 | 
			
		||||
	return activity;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										23
									
								
								packages/backend/src/remote/activitypub/renderer/vote.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/backend/src/remote/activitypub/renderer/vote.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { Note } from '@/models/entities/note';
 | 
			
		||||
import { IRemoteUser, User } from '@/models/entities/user';
 | 
			
		||||
import { PollVote } from '@/models/entities/poll-vote';
 | 
			
		||||
import { Poll } from '@/models/entities/poll';
 | 
			
		||||
 | 
			
		||||
export default async function renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: IRemoteUser): Promise<any> {
 | 
			
		||||
	return {
 | 
			
		||||
		id: `${config.url}/users/${user.id}#votes/${vote.id}/activity`,
 | 
			
		||||
		actor: `${config.url}/users/${user.id}`,
 | 
			
		||||
		type: 'Create',
 | 
			
		||||
		to: [pollOwner.uri],
 | 
			
		||||
		published: new Date().toISOString(),
 | 
			
		||||
		object: {
 | 
			
		||||
			id: `${config.url}/users/${user.id}#votes/${vote.id}`,
 | 
			
		||||
			type: 'Note',
 | 
			
		||||
			attributedTo: `${config.url}/users/${user.id}`,
 | 
			
		||||
			to: [pollOwner.uri],
 | 
			
		||||
			inReplyTo: note.uri,
 | 
			
		||||
			name: poll.choices[vote.choice]
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										58
									
								
								packages/backend/src/remote/activitypub/request.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								packages/backend/src/remote/activitypub/request.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,58 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { getUserKeypair } from '@/misc/keypair-store';
 | 
			
		||||
import { User } from '@/models/entities/user';
 | 
			
		||||
import { getResponse } from '../../misc/fetch';
 | 
			
		||||
import { createSignedPost, createSignedGet } from './ap-request';
 | 
			
		||||
 | 
			
		||||
export default async (user: { id: User['id'] }, url: string, object: any) => {
 | 
			
		||||
	const body = JSON.stringify(object);
 | 
			
		||||
 | 
			
		||||
	const keypair = await getUserKeypair(user.id);
 | 
			
		||||
 | 
			
		||||
	const req = createSignedPost({
 | 
			
		||||
		key: {
 | 
			
		||||
			privateKeyPem: keypair.privateKey,
 | 
			
		||||
			keyId: `${config.url}/users/${user.id}#main-key`
 | 
			
		||||
		},
 | 
			
		||||
		url,
 | 
			
		||||
		body,
 | 
			
		||||
		additionalHeaders: {
 | 
			
		||||
			'User-Agent': config.userAgent,
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	await getResponse({
 | 
			
		||||
		url,
 | 
			
		||||
		method: req.request.method,
 | 
			
		||||
		headers: req.request.headers,
 | 
			
		||||
		body,
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get AP object with http-signature
 | 
			
		||||
 * @param user http-signature user
 | 
			
		||||
 * @param url URL to fetch
 | 
			
		||||
 */
 | 
			
		||||
export async function signedGet(url: string, user: { id: User['id'] }) {
 | 
			
		||||
	const keypair = await getUserKeypair(user.id);
 | 
			
		||||
 | 
			
		||||
	const req = createSignedGet({
 | 
			
		||||
		key: {
 | 
			
		||||
			privateKeyPem: keypair.privateKey,
 | 
			
		||||
			keyId: `${config.url}/users/${user.id}#main-key`
 | 
			
		||||
		},
 | 
			
		||||
		url,
 | 
			
		||||
		additionalHeaders: {
 | 
			
		||||
			'User-Agent': config.userAgent,
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const res = await getResponse({
 | 
			
		||||
		url,
 | 
			
		||||
		method: req.request.method,
 | 
			
		||||
		headers: req.request.headers
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	return await res.json();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										73
									
								
								packages/backend/src/remote/activitypub/resolver.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								packages/backend/src/remote/activitypub/resolver.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,73 @@
 | 
			
		|||
import config from '@/config/index';
 | 
			
		||||
import { getJson } from '@/misc/fetch';
 | 
			
		||||
import { ILocalUser } from '@/models/entities/user';
 | 
			
		||||
import { getInstanceActor } from '@/services/instance-actor';
 | 
			
		||||
import { signedGet } from './request';
 | 
			
		||||
import { IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type';
 | 
			
		||||
import { fetchMeta } from '@/misc/fetch-meta';
 | 
			
		||||
import { extractDbHost } from '@/misc/convert-host';
 | 
			
		||||
 | 
			
		||||
export default class Resolver {
 | 
			
		||||
	private history: Set<string>;
 | 
			
		||||
	private user?: ILocalUser;
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		this.history = new Set();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public getHistory(): string[] {
 | 
			
		||||
		return Array.from(this.history);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
 | 
			
		||||
		const collection = typeof value === 'string'
 | 
			
		||||
			? await this.resolve(value)
 | 
			
		||||
			: value;
 | 
			
		||||
 | 
			
		||||
		if (isCollectionOrOrderedCollection(collection)) {
 | 
			
		||||
			return collection;
 | 
			
		||||
		} else {
 | 
			
		||||
			throw new Error(`unrecognized collection type: ${collection.type}`);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async resolve(value: string | IObject): Promise<IObject> {
 | 
			
		||||
		if (value == null) {
 | 
			
		||||
			throw new Error('resolvee is null (or undefined)');
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (typeof value !== 'string') {
 | 
			
		||||
			return value;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.history.has(value)) {
 | 
			
		||||
			throw new Error('cannot resolve already resolved one');
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.history.add(value);
 | 
			
		||||
 | 
			
		||||
		const meta = await fetchMeta();
 | 
			
		||||
		const host = extractDbHost(value);
 | 
			
		||||
		if (meta.blockedHosts.includes(host)) {
 | 
			
		||||
			throw new Error('Instance is blocked');
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (config.signToActivityPubGet && !this.user) {
 | 
			
		||||
			this.user = await getInstanceActor();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const object = this.user
 | 
			
		||||
			? await signedGet(value, this.user)
 | 
			
		||||
			: await getJson(value, 'application/activity+json, application/ld+json');
 | 
			
		||||
 | 
			
		||||
		if (object == null || (
 | 
			
		||||
			Array.isArray(object['@context']) ?
 | 
			
		||||
				!object['@context'].includes('https://www.w3.org/ns/activitystreams') :
 | 
			
		||||
				object['@context'] !== 'https://www.w3.org/ns/activitystreams'
 | 
			
		||||
		)) {
 | 
			
		||||
			throw new Error('invalid response');
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return object;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										289
									
								
								packages/backend/src/remote/activitypub/type.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										289
									
								
								packages/backend/src/remote/activitypub/type.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,289 @@
 | 
			
		|||
export type obj = { [x: string]: any };
 | 
			
		||||
export type ApObject = IObject | string | (IObject | string)[];
 | 
			
		||||
 | 
			
		||||
export interface IObject {
 | 
			
		||||
	'@context': string | obj | obj[];
 | 
			
		||||
	type: string | string[];
 | 
			
		||||
	id?: string;
 | 
			
		||||
	summary?: string;
 | 
			
		||||
	published?: string;
 | 
			
		||||
	cc?: ApObject;
 | 
			
		||||
	to?: ApObject;
 | 
			
		||||
	attributedTo: ApObject;
 | 
			
		||||
	attachment?: any[];
 | 
			
		||||
	inReplyTo?: any;
 | 
			
		||||
	replies?: ICollection;
 | 
			
		||||
	content?: string;
 | 
			
		||||
	name?: string;
 | 
			
		||||
	startTime?: Date;
 | 
			
		||||
	endTime?: Date;
 | 
			
		||||
	icon?: any;
 | 
			
		||||
	image?: any;
 | 
			
		||||
	url?: ApObject;
 | 
			
		||||
	href?: string;
 | 
			
		||||
	tag?: IObject | IObject[];
 | 
			
		||||
	sensitive?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get array of ActivityStreams Objects id
 | 
			
		||||
 */
 | 
			
		||||
export function getApIds(value: ApObject | undefined): string[] {
 | 
			
		||||
	if (value == null) return [];
 | 
			
		||||
	const array = Array.isArray(value) ? value : [value];
 | 
			
		||||
	return array.map(x => getApId(x));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get first ActivityStreams Object id
 | 
			
		||||
 */
 | 
			
		||||
export function getOneApId(value: ApObject): string {
 | 
			
		||||
	const firstOne = Array.isArray(value) ? value[0] : value;
 | 
			
		||||
	return getApId(firstOne);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get ActivityStreams Object id
 | 
			
		||||
 */
 | 
			
		||||
export function getApId(value: string | IObject): string {
 | 
			
		||||
	if (typeof value === 'string') return value;
 | 
			
		||||
	if (typeof value.id === 'string') return value.id;
 | 
			
		||||
	throw new Error(`cannot detemine id`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get ActivityStreams Object type
 | 
			
		||||
 */
 | 
			
		||||
export function getApType(value: IObject): string {
 | 
			
		||||
	if (typeof value.type === 'string') return value.type;
 | 
			
		||||
	if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0];
 | 
			
		||||
	throw new Error(`cannot detect type`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
 | 
			
		||||
	const firstOne = Array.isArray(value) ? value[0] : value;
 | 
			
		||||
	return getApHrefNullable(firstOne);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getApHrefNullable(value: string | IObject | undefined): string | undefined {
 | 
			
		||||
	if (typeof value === 'string') return value;
 | 
			
		||||
	if (typeof value?.href === 'string') return value.href;
 | 
			
		||||
	return undefined;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IActivity extends IObject {
 | 
			
		||||
	//type: 'Activity';
 | 
			
		||||
	actor: IObject | string;
 | 
			
		||||
	object: IObject | string;
 | 
			
		||||
	target?: IObject | string;
 | 
			
		||||
	/** LD-Signature */
 | 
			
		||||
	signature?: {
 | 
			
		||||
		type: string;
 | 
			
		||||
		created: Date;
 | 
			
		||||
		creator: string;
 | 
			
		||||
		domain?: string;
 | 
			
		||||
		nonce?: string;
 | 
			
		||||
		signatureValue: string;
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ICollection extends IObject {
 | 
			
		||||
	type: 'Collection';
 | 
			
		||||
	totalItems: number;
 | 
			
		||||
	items: ApObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IOrderedCollection extends IObject {
 | 
			
		||||
	type: 'OrderedCollection';
 | 
			
		||||
	totalItems: number;
 | 
			
		||||
	orderedItems: ApObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
 | 
			
		||||
 | 
			
		||||
export const isPost = (object: IObject): object is IPost =>
 | 
			
		||||
	validPost.includes(getApType(object));
 | 
			
		||||
 | 
			
		||||
export interface IPost extends IObject {
 | 
			
		||||
	type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event';
 | 
			
		||||
	_misskey_content?: string;
 | 
			
		||||
	_misskey_quote?: string;
 | 
			
		||||
	quoteUrl?: string;
 | 
			
		||||
	_misskey_talk: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IQuestion extends IObject {
 | 
			
		||||
	type: 'Note' | 'Question';
 | 
			
		||||
	_misskey_content?: string;
 | 
			
		||||
	_misskey_quote?: string;
 | 
			
		||||
	quoteUrl?: string;
 | 
			
		||||
	oneOf?: IQuestionChoice[];
 | 
			
		||||
	anyOf?: IQuestionChoice[];
 | 
			
		||||
	endTime?: Date;
 | 
			
		||||
	closed?: Date;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isQuestion = (object: IObject): object is IQuestion =>
 | 
			
		||||
	getApType(object) === 'Note' || getApType(object) === 'Question';
 | 
			
		||||
 | 
			
		||||
interface IQuestionChoice {
 | 
			
		||||
	name?: string;
 | 
			
		||||
	replies?: ICollection;
 | 
			
		||||
	_misskey_votes?: number;
 | 
			
		||||
}
 | 
			
		||||
export interface ITombstone extends IObject {
 | 
			
		||||
	type: 'Tombstone';
 | 
			
		||||
	formerType?: string;
 | 
			
		||||
	deleted?: Date;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isTombstone = (object: IObject): object is ITombstone =>
 | 
			
		||||
	getApType(object) === 'Tombstone';
 | 
			
		||||
 | 
			
		||||
export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application'];
 | 
			
		||||
 | 
			
		||||
export const isActor = (object: IObject): object is IActor =>
 | 
			
		||||
	validActor.includes(getApType(object));
 | 
			
		||||
 | 
			
		||||
export interface IActor extends IObject {
 | 
			
		||||
	type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application';
 | 
			
		||||
	name?: string;
 | 
			
		||||
	preferredUsername?: string;
 | 
			
		||||
	manuallyApprovesFollowers?: boolean;
 | 
			
		||||
	discoverable?: boolean;
 | 
			
		||||
	inbox: string;
 | 
			
		||||
	sharedInbox?: string;	// 後方互換性のため
 | 
			
		||||
	publicKey?: {
 | 
			
		||||
		id: string;
 | 
			
		||||
		publicKeyPem: string;
 | 
			
		||||
	};
 | 
			
		||||
	followers?: string | ICollection | IOrderedCollection;
 | 
			
		||||
	following?: string | ICollection | IOrderedCollection;
 | 
			
		||||
	featured?: string | IOrderedCollection;
 | 
			
		||||
	outbox: string | IOrderedCollection;
 | 
			
		||||
	endpoints?: {
 | 
			
		||||
		sharedInbox?: string;
 | 
			
		||||
	};
 | 
			
		||||
	'vcard:bday'?: string;
 | 
			
		||||
	'vcard:Address'?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isCollection = (object: IObject): object is ICollection =>
 | 
			
		||||
	getApType(object) === 'Collection';
 | 
			
		||||
 | 
			
		||||
export const isOrderedCollection = (object: IObject): object is IOrderedCollection =>
 | 
			
		||||
	getApType(object) === 'OrderedCollection';
 | 
			
		||||
 | 
			
		||||
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
 | 
			
		||||
	isCollection(object) || isOrderedCollection(object);
 | 
			
		||||
 | 
			
		||||
export interface IApPropertyValue extends IObject {
 | 
			
		||||
	type: 'PropertyValue';
 | 
			
		||||
	identifier: IApPropertyValue;
 | 
			
		||||
	name: string;
 | 
			
		||||
	value: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isPropertyValue = (object: IObject): object is IApPropertyValue =>
 | 
			
		||||
	object &&
 | 
			
		||||
	getApType(object) === 'PropertyValue' &&
 | 
			
		||||
	typeof object.name === 'string' &&
 | 
			
		||||
	typeof (object as any).value === 'string';
 | 
			
		||||
 | 
			
		||||
export interface IApMention extends IObject {
 | 
			
		||||
	type: 'Mention';
 | 
			
		||||
	href: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isMention = (object: IObject): object is IApMention=>
 | 
			
		||||
	getApType(object) === 'Mention' &&
 | 
			
		||||
	typeof object.href === 'string';
 | 
			
		||||
 | 
			
		||||
export interface IApHashtag extends IObject {
 | 
			
		||||
	type: 'Hashtag';
 | 
			
		||||
	name: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isHashtag = (object: IObject): object is IApHashtag =>
 | 
			
		||||
	getApType(object) === 'Hashtag' &&
 | 
			
		||||
	typeof object.name === 'string';
 | 
			
		||||
 | 
			
		||||
export interface IApEmoji extends IObject {
 | 
			
		||||
	type: 'Emoji';
 | 
			
		||||
	updated: Date;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isEmoji = (object: IObject): object is IApEmoji =>
 | 
			
		||||
	getApType(object) === 'Emoji' && !Array.isArray(object.icon) && object.icon.url != null;
 | 
			
		||||
 | 
			
		||||
export interface ICreate extends IActivity {
 | 
			
		||||
	type: 'Create';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IDelete extends IActivity {
 | 
			
		||||
	type: 'Delete';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IUpdate extends IActivity {
 | 
			
		||||
	type: 'Update';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IRead extends IActivity {
 | 
			
		||||
	type: 'Read';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IUndo extends IActivity {
 | 
			
		||||
	type: 'Undo';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IFollow extends IActivity {
 | 
			
		||||
	type: 'Follow';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IAccept extends IActivity {
 | 
			
		||||
	type: 'Accept';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IReject extends IActivity {
 | 
			
		||||
	type: 'Reject';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IAdd extends IActivity {
 | 
			
		||||
	type: 'Add';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IRemove extends IActivity {
 | 
			
		||||
	type: 'Remove';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ILike extends IActivity {
 | 
			
		||||
	type: 'Like' | 'EmojiReaction' | 'EmojiReact';
 | 
			
		||||
	_misskey_reaction?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IAnnounce extends IActivity {
 | 
			
		||||
	type: 'Announce';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IBlock extends IActivity {
 | 
			
		||||
	type: 'Block';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IFlag extends IActivity {
 | 
			
		||||
	type: 'Flag';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
 | 
			
		||||
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
 | 
			
		||||
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
 | 
			
		||||
export const isRead = (object: IObject): object is IRead => getApType(object) === 'Read';
 | 
			
		||||
export const isUndo = (object: IObject): object is IUndo => getApType(object) === 'Undo';
 | 
			
		||||
export const isFollow = (object: IObject): object is IFollow => getApType(object) === 'Follow';
 | 
			
		||||
export const isAccept = (object: IObject): object is IAccept => getApType(object) === 'Accept';
 | 
			
		||||
export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject';
 | 
			
		||||
export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add';
 | 
			
		||||
export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove';
 | 
			
		||||
export const isLike = (object: IObject): object is ILike => getApType(object) === 'Like' || getApType(object) === 'EmojiReaction' || getApType(object) === 'EmojiReact';
 | 
			
		||||
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
 | 
			
		||||
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
 | 
			
		||||
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
 | 
			
		||||
							
								
								
									
										3
									
								
								packages/backend/src/remote/logger.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/backend/src/remote/logger.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
import Logger from '@/services/logger';
 | 
			
		||||
 | 
			
		||||
export const remoteLogger = new Logger('remote', 'cyan');
 | 
			
		||||
							
								
								
									
										110
									
								
								packages/backend/src/remote/resolve-user.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								packages/backend/src/remote/resolve-user.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,110 @@
 | 
			
		|||
import { URL } from 'url';
 | 
			
		||||
import webFinger from './webfinger';
 | 
			
		||||
import config from '@/config/index';
 | 
			
		||||
import { createPerson, updatePerson } from './activitypub/models/person';
 | 
			
		||||
import { remoteLogger } from './logger';
 | 
			
		||||
import * as chalk from 'chalk';
 | 
			
		||||
import { User, IRemoteUser } from '@/models/entities/user';
 | 
			
		||||
import { Users } from '@/models/index';
 | 
			
		||||
import { toPuny } from '@/misc/convert-host';
 | 
			
		||||
 | 
			
		||||
const logger = remoteLogger.createSubLogger('resolve-user');
 | 
			
		||||
 | 
			
		||||
export async function resolveUser(username: string, host: string | null, option?: any, resync = false): Promise<User> {
 | 
			
		||||
	const usernameLower = username.toLowerCase();
 | 
			
		||||
 | 
			
		||||
	if (host == null) {
 | 
			
		||||
		logger.info(`return local user: ${usernameLower}`);
 | 
			
		||||
		return await Users.findOne({ usernameLower, host: null }).then(u => {
 | 
			
		||||
			if (u == null) {
 | 
			
		||||
				throw new Error('user not found');
 | 
			
		||||
			} else {
 | 
			
		||||
				return u;
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	host = toPuny(host);
 | 
			
		||||
 | 
			
		||||
	if (config.host == host) {
 | 
			
		||||
		logger.info(`return local user: ${usernameLower}`);
 | 
			
		||||
		return await Users.findOne({ usernameLower, host: null }).then(u => {
 | 
			
		||||
			if (u == null) {
 | 
			
		||||
				throw new Error('user not found');
 | 
			
		||||
			} else {
 | 
			
		||||
				return u;
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const user = await Users.findOne({ usernameLower, host }, option) as IRemoteUser;
 | 
			
		||||
 | 
			
		||||
	const acctLower = `${usernameLower}@${host}`;
 | 
			
		||||
 | 
			
		||||
	if (user == null) {
 | 
			
		||||
		const self = await resolveSelf(acctLower);
 | 
			
		||||
 | 
			
		||||
		logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
 | 
			
		||||
		return await createPerson(self.href);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// resyncオプション OR ユーザー情報が古い場合は、WebFilgerからやりなおして返す
 | 
			
		||||
	if (resync || user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
 | 
			
		||||
		// 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する
 | 
			
		||||
		await Users.update(user.id, {
 | 
			
		||||
			lastFetchedAt: new Date(),
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		logger.info(`try resync: ${acctLower}`);
 | 
			
		||||
		const self = await resolveSelf(acctLower);
 | 
			
		||||
 | 
			
		||||
		if (user.uri !== self.href) {
 | 
			
		||||
			// if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping.
 | 
			
		||||
			logger.info(`uri missmatch: ${acctLower}`);
 | 
			
		||||
			logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`);
 | 
			
		||||
 | 
			
		||||
			// validate uri
 | 
			
		||||
			const uri = new URL(self.href);
 | 
			
		||||
			if (uri.hostname !== host) {
 | 
			
		||||
				throw new Error(`Invalid uri`);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			await Users.update({
 | 
			
		||||
				usernameLower,
 | 
			
		||||
				host: host
 | 
			
		||||
			}, {
 | 
			
		||||
				uri: self.href
 | 
			
		||||
			});
 | 
			
		||||
		} else {
 | 
			
		||||
			logger.info(`uri is fine: ${acctLower}`);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		await updatePerson(self.href);
 | 
			
		||||
 | 
			
		||||
		logger.info(`return resynced remote user: ${acctLower}`);
 | 
			
		||||
		return await Users.findOne({ uri: self.href }).then(u => {
 | 
			
		||||
			if (u == null) {
 | 
			
		||||
				throw new Error('user not found');
 | 
			
		||||
			} else {
 | 
			
		||||
				return u;
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	logger.info(`return existing remote user: ${acctLower}`);
 | 
			
		||||
	return user;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function resolveSelf(acctLower: string) {
 | 
			
		||||
	logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
 | 
			
		||||
	const finger = await webFinger(acctLower).catch(e => {
 | 
			
		||||
		logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ e.statusCode || e.message }`);
 | 
			
		||||
		throw new Error(`Failed to WebFinger for ${acctLower}: ${ e.statusCode || e.message }`);
 | 
			
		||||
	});
 | 
			
		||||
	const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self');
 | 
			
		||||
	if (!self) {
 | 
			
		||||
		logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`);
 | 
			
		||||
		throw new Error('self link not found');
 | 
			
		||||
	}
 | 
			
		||||
	return self;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										34
									
								
								packages/backend/src/remote/webfinger.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								packages/backend/src/remote/webfinger.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import { URL } from 'url';
 | 
			
		||||
import { getJson } from '@/misc/fetch';
 | 
			
		||||
import { query as urlQuery } from '@/prelude/url';
 | 
			
		||||
 | 
			
		||||
type ILink = {
 | 
			
		||||
	href: string;
 | 
			
		||||
	rel?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type IWebFinger = {
 | 
			
		||||
	links: ILink[];
 | 
			
		||||
	subject: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default async function(query: string): Promise<IWebFinger> {
 | 
			
		||||
	const url = genUrl(query);
 | 
			
		||||
 | 
			
		||||
	return await getJson(url, 'application/jrd+json, application/json');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function genUrl(query: string) {
 | 
			
		||||
	if (query.match(/^https?:\/\//)) {
 | 
			
		||||
		const u = new URL(query);
 | 
			
		||||
		return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query });
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const m = query.match(/^([^@]+)@(.*)/);
 | 
			
		||||
	if (m) {
 | 
			
		||||
		const hostname = m[2];
 | 
			
		||||
		return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` });
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	throw new Error(`Invalid query (${query})`);
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue