refactoring

Resolve #7779
This commit is contained in:
syuilo
2021-11-12 02:02:25 +09:00
parent 037837b551
commit 0e4a111f81
1714 changed files with 20803 additions and 11751 deletions

View 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));
}

View 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`)
);
}

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

View 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

View File

@ -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`;
};

View File

@ -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)}`;
};

View 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}`);
};

View File

@ -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);
};

View File

@ -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();
}
}

View File

@ -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`;
};

View File

@ -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)}`);
}
};

View File

@ -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();
}
}

View File

@ -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}`;
}

View File

@ -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}`;
}
};

View File

@ -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();
}
}

View 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`;
};

View 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`;
};

View 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}`);
}
}

View 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');
};

View 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})`;
};

View File

@ -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`;
};

View File

@ -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)}`;
};

View File

@ -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}`);
};

View File

@ -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';
};

View 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`;
};

View File

@ -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: リクエストもフォローもされていない`;
};

View 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)}`;
};

View 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`;
};

View File

@ -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)}`;
}
};

View File

@ -0,0 +1,3 @@
import { remoteLogger } from '../logger';
export const apLogger = remoteLogger.createSubLogger('ap', 'magenta');

View 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,
};

View File

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

View File

@ -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);
}

View 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');
}
}

View File

@ -0,0 +1,5 @@
export type IIcon = {
type: string;
mediaType?: string;
url?: string;
};

View File

@ -0,0 +1,5 @@
export type IIdentifier = {
type: string;
name: string;
value: string;
};

View 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);
}

View 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);
}

View 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>);
}));
}

View 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
});
}
});
}

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

View 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);
}

View 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);
};

View File

@ -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
});

View 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
});

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

View File

@ -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
});

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

View File

@ -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(),
});

View File

@ -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,
});

View 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
}
});

View File

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

View File

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

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

View File

@ -0,0 +1,7 @@
import config from '@/config/index';
export default (tag: string) => ({
type: 'Hashtag',
href: `${config.url}/tags/${encodeURIComponent(tag)}`,
name: `#${tag}`
});

View File

@ -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
});

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

View 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'
})
});

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

View File

@ -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}`,
});

View 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[];
}

View File

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

View File

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

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

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

View 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
});

View File

@ -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
});

View File

@ -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
});

View File

@ -0,0 +1,4 @@
export default (id: string) => ({
id,
type: 'Tombstone'
});

View 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(),
};
};

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

View 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]
}
};
}

View 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();
}

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

View 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';

View File

@ -0,0 +1,3 @@
import Logger from '@/services/logger';
export const remoteLogger = new Logger('remote', 'cyan');

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

View 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})`);
}