forked from mirrors/misskey
なんかもうめっちゃ変えた
This commit is contained in:
12
packages/backend/src/core/remote/RemoteLoggerService.ts
Normal file
12
packages/backend/src/core/remote/RemoteLoggerService.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Logger from '@/logger.js';
|
||||
|
||||
@Injectable()
|
||||
export class RemoteLoggerService {
|
||||
public logger: Logger;
|
||||
|
||||
constructor(
|
||||
) {
|
||||
this.logger = new Logger('remote', 'cyan');
|
||||
}
|
||||
}
|
||||
132
packages/backend/src/core/remote/ResolveUserService.ts
Normal file
132
packages/backend/src/core/remote/ResolveUserService.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import chalk from 'chalk';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import type { IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { UtilityService } from '../UtilityService.js';
|
||||
import { WebfingerService } from './WebfingerService.js';
|
||||
import { RemoteLoggerService } from './RemoteLoggerService.js';
|
||||
import { ApPersonService } from './activitypub/models/ApPersonService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ResolveUserService {
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private webfingerService: WebfingerService,
|
||||
private remoteLoggerService: RemoteLoggerService,
|
||||
private apPersonService: ApPersonService,
|
||||
) {
|
||||
this.#logger = this.remoteLoggerService.logger.createSubLogger('resolve-user');
|
||||
}
|
||||
|
||||
public async resolveUser(username: string, host: string | null): Promise<User> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
|
||||
if (host == null) {
|
||||
this.#logger.info(`return local user: ${usernameLower}`);
|
||||
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
||||
if (u == null) {
|
||||
throw new Error('user not found');
|
||||
} else {
|
||||
return u;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
if (this.config.host === host) {
|
||||
this.#logger.info(`return local user: ${usernameLower}`);
|
||||
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
||||
if (u == null) {
|
||||
throw new Error('user not found');
|
||||
} else {
|
||||
return u;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as IRemoteUser | null;
|
||||
|
||||
const acctLower = `${usernameLower}@${host}`;
|
||||
|
||||
if (user == null) {
|
||||
const self = await this.#resolveSelf(acctLower);
|
||||
|
||||
this.#logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
|
||||
return await this.apPersonService.createPerson(self.href);
|
||||
}
|
||||
|
||||
// ユーザー情報が古い場合は、WebFilgerからやりなおして返す
|
||||
if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||
// 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する
|
||||
await this.usersRepository.update(user.id, {
|
||||
lastFetchedAt: new Date(),
|
||||
});
|
||||
|
||||
this.#logger.info(`try resync: ${acctLower}`);
|
||||
const self = await this.#resolveSelf(acctLower);
|
||||
|
||||
if (user.uri !== self.href) {
|
||||
// if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping.
|
||||
this.#logger.info(`uri missmatch: ${acctLower}`);
|
||||
this.#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 this.usersRepository.update({
|
||||
usernameLower,
|
||||
host: host,
|
||||
}, {
|
||||
uri: self.href,
|
||||
});
|
||||
} else {
|
||||
this.#logger.info(`uri is fine: ${acctLower}`);
|
||||
}
|
||||
|
||||
await this.apPersonService.updatePerson(self.href);
|
||||
|
||||
this.#logger.info(`return resynced remote user: ${acctLower}`);
|
||||
return await this.usersRepository.findOneBy({ uri: self.href }).then(u => {
|
||||
if (u == null) {
|
||||
throw new Error('user not found');
|
||||
} else {
|
||||
return u;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.#logger.info(`return existing remote user: ${acctLower}`);
|
||||
return user;
|
||||
}
|
||||
|
||||
async #resolveSelf(acctLower: string) {
|
||||
this.#logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
|
||||
const finger = await this.webfingerService.webfinger(acctLower).catch(err => {
|
||||
this.#logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`);
|
||||
throw new Error(`Failed to WebFinger for ${acctLower}: ${ err.statusCode ?? err.message }`);
|
||||
});
|
||||
const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self');
|
||||
if (!self) {
|
||||
this.#logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`);
|
||||
throw new Error('self link not found');
|
||||
}
|
||||
return self;
|
||||
}
|
||||
}
|
||||
48
packages/backend/src/core/remote/WebfingerService.ts
Normal file
48
packages/backend/src/core/remote/WebfingerService.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { query as urlQuery } from '@/misc/prelude/url.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
|
||||
type ILink = {
|
||||
href: string;
|
||||
rel?: string;
|
||||
};
|
||||
|
||||
type IWebFinger = {
|
||||
links: ILink[];
|
||||
subject: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WebfingerService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async webfinger(query: string): Promise<IWebFinger> {
|
||||
const url = this.#genUrl(query);
|
||||
|
||||
return await this.httpRequestService.getJson(url, 'application/jrd+json, application/json') as IWebFinger;
|
||||
}
|
||||
|
||||
#genUrl(query: string): 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})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { CacheableRemoteUser, CacheableUser } from '@/models/entities/User.js';
|
||||
import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
|
||||
import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import type { ApObject } from './type.js';
|
||||
import type { Resolver } from './ApResolverService.js';
|
||||
|
||||
type Visibility = 'public' | 'home' | 'followers' | 'specified';
|
||||
|
||||
type AudienceInfo = {
|
||||
visibility: Visibility,
|
||||
mentionedUsers: CacheableUser[],
|
||||
visibleUsers: CacheableUser[],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApAudienceService {
|
||||
constructor(
|
||||
private apPersonService: ApPersonService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async parseAudience(actor: CacheableRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise<AudienceInfo> {
|
||||
const toGroups = this.#groupingAudience(getApIds(to), actor);
|
||||
const ccGroups = this.#groupingAudience(getApIds(cc), actor);
|
||||
|
||||
const others = unique(concat([toGroups.other, ccGroups.other]));
|
||||
|
||||
const limit = promiseLimit<CacheableUser | null>(2);
|
||||
const mentionedUsers = (await Promise.all(
|
||||
others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))),
|
||||
)).filter((x): x is CacheableUser => 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,
|
||||
};
|
||||
}
|
||||
|
||||
#groupingAudience(ids: string[], actor: CacheableRemoteUser) {
|
||||
const groups = {
|
||||
public: [] as string[],
|
||||
followers: [] as string[],
|
||||
other: [] as string[],
|
||||
};
|
||||
|
||||
for (const id of ids) {
|
||||
if (this.#isPublic(id)) {
|
||||
groups.public.push(id);
|
||||
} else if (this.#isFollowers(id, actor)) {
|
||||
groups.followers.push(id);
|
||||
} else {
|
||||
groups.other.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
groups.other = unique(groups.other);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
#isPublic(id: string) {
|
||||
return [
|
||||
'https://www.w3.org/ns/activitystreams#Public',
|
||||
'as#Public',
|
||||
'Public',
|
||||
].includes(id);
|
||||
}
|
||||
|
||||
#isFollowers(id: string, actor: CacheableRemoteUser) {
|
||||
return (
|
||||
id === (actor.followersUri ?? `${actor.uri}/followers`)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import escapeRegexp from 'escape-regexp';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MessagingMessagesRepository, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { CacheableRemoteUser, CacheableUser } from '@/models/entities/User.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { UserPublickey } from '@/models/entities/UserPublickey.js';
|
||||
import { UserCacheService } from '@/core/UserCacheService.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import type { MessagingMessage } from '@/models/entities/MessagingMessage.js';
|
||||
import { getApId } from './type.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import type { IObject } from './type.js';
|
||||
|
||||
export type UriParseResult = {
|
||||
/** wether the URI was generated by us */
|
||||
local: true;
|
||||
/** id in DB */
|
||||
id: string;
|
||||
/** hint of type, e.g. "notes", "users" */
|
||||
type: string;
|
||||
/** any remaining text after type and id, not including the slash after id. undefined if empty */
|
||||
rest?: string;
|
||||
} | {
|
||||
/** wether the URI was generated by us */
|
||||
local: false;
|
||||
/** uri in DB */
|
||||
uri: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApDbResolverService {
|
||||
#publicKeyCache: Cache<UserPublickey | null>;
|
||||
#publicKeyByUserIdCache: Cache<UserPublickey | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.messagingMessagesRepository)
|
||||
private messagingMessagesRepository: MessagingMessagesRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.userPublickeysRepository)
|
||||
private userPublickeysRepository: UserPublickeysRepository,
|
||||
|
||||
private userCacheService: UserCacheService,
|
||||
private apPersonService: ApPersonService,
|
||||
) {
|
||||
this.#publicKeyCache = new Cache<UserPublickey | null>(Infinity);
|
||||
this.#publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity);
|
||||
}
|
||||
|
||||
public parseUri(value: string | IObject): UriParseResult {
|
||||
const uri = getApId(value);
|
||||
|
||||
// the host part of a URL is case insensitive, so use the 'i' flag.
|
||||
const localRegex = new RegExp('^' + escapeRegexp(this.config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i');
|
||||
const matchLocal = uri.match(localRegex);
|
||||
|
||||
if (matchLocal) {
|
||||
return {
|
||||
local: true,
|
||||
type: matchLocal[1],
|
||||
id: matchLocal[2],
|
||||
rest: matchLocal[3],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
local: false,
|
||||
uri,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AP Note => Misskey Note in DB
|
||||
*/
|
||||
public async getNoteFromApId(value: string | IObject): Promise<Note | null> {
|
||||
const parsed = this.parseUri(value);
|
||||
|
||||
if (parsed.local) {
|
||||
if (parsed.type !== 'notes') return null;
|
||||
|
||||
return await this.notesRepository.findOneBy({
|
||||
id: parsed.id,
|
||||
});
|
||||
} else {
|
||||
return await this.notesRepository.findOneBy({
|
||||
uri: parsed.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async getMessageFromApId(value: string | IObject): Promise<MessagingMessage | null> {
|
||||
const parsed = this.parseUri(value);
|
||||
|
||||
if (parsed.local) {
|
||||
if (parsed.type !== 'notes') return null;
|
||||
|
||||
return await this.messagingMessagesRepository.findOneBy({
|
||||
id: parsed.id,
|
||||
});
|
||||
} else {
|
||||
return await this.messagingMessagesRepository.findOneBy({
|
||||
uri: parsed.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AP Person => Misskey User in DB
|
||||
*/
|
||||
public async getUserFromApId(value: string | IObject): Promise<CacheableUser | null> {
|
||||
const parsed = this.parseUri(value);
|
||||
|
||||
if (parsed.local) {
|
||||
if (parsed.type !== 'users') return null;
|
||||
|
||||
return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({
|
||||
id: parsed.id,
|
||||
}).then(x => x ?? undefined)) ?? null;
|
||||
} else {
|
||||
return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({
|
||||
uri: parsed.uri,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AP KeyId => Misskey User and Key
|
||||
*/
|
||||
public async getAuthUserFromKeyId(keyId: string): Promise<{
|
||||
user: CacheableRemoteUser;
|
||||
key: UserPublickey;
|
||||
} | null> {
|
||||
const key = await this.#publicKeyCache.fetch(keyId, async () => {
|
||||
const key = await this.userPublickeysRepository.findOneBy({
|
||||
keyId,
|
||||
});
|
||||
|
||||
if (key == null) return null;
|
||||
|
||||
return key;
|
||||
}, key => key != null);
|
||||
|
||||
if (key == null) return null;
|
||||
|
||||
return {
|
||||
user: await this.userCacheService.userByIdCache.fetch(key.userId, () => this.usersRepository.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser,
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AP Actor id => Misskey User and Key
|
||||
*/
|
||||
public async getAuthUserFromApId(uri: string): Promise<{
|
||||
user: CacheableRemoteUser;
|
||||
key: UserPublickey | null;
|
||||
} | null> {
|
||||
const user = await this.apPersonService.resolvePerson(uri) as CacheableRemoteUser;
|
||||
|
||||
if (user == null) return null;
|
||||
|
||||
const key = await this.#publicKeyByUserIdCache.fetch(user.id, () => this.userPublickeysRepository.findOneBy({ userId: user.id }), v => v != null);
|
||||
|
||||
return {
|
||||
user,
|
||||
key,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { FollowingsRepository, UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class ApDeliverManagerService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver activity to followers
|
||||
* @param activity Activity
|
||||
* @param from Followee
|
||||
*/
|
||||
public async deliverToFollowers(actor: { id: ILocalUser['id']; host: null; }, activity: any) {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
manager.addFollowersRecipe();
|
||||
await manager.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver activity to user
|
||||
* @param activity Activity
|
||||
* @param to Target user
|
||||
*/
|
||||
public async deliverToUser(actor: { id: ILocalUser['id']; host: null; }, activity: any, to: IRemoteUser) {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
manager.addDirectRecipe(to);
|
||||
await manager.execute();
|
||||
}
|
||||
|
||||
public createDeliverManager(actor: { id: User['id']; host: null; }, activity: any) {
|
||||
return new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
private userEntityService: UserEntityService,
|
||||
private followingsRepository: FollowingsRepository,
|
||||
private queueService: QueueService,
|
||||
|
||||
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 (!this.userEntityService.isLocalUser(this.actor)) return;
|
||||
|
||||
const inboxes = new Set<string>();
|
||||
|
||||
/*
|
||||
build inbox list
|
||||
|
||||
Process follower recipes first to avoid duplication when processing
|
||||
direct recipes later.
|
||||
*/
|
||||
if (this.recipes.some(r => isFollowers(r))) {
|
||||
// followers deliver
|
||||
// TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
|
||||
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう?
|
||||
const followers = await this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: this.actor.id,
|
||||
followerHost: Not(IsNull()),
|
||||
},
|
||||
select: {
|
||||
followerSharedInbox: true,
|
||||
followerInbox: true,
|
||||
},
|
||||
}) as {
|
||||
followerSharedInbox: string | null;
|
||||
followerInbox: string;
|
||||
}[];
|
||||
|
||||
for (const following of followers) {
|
||||
const inbox = following.followerSharedInbox ?? following.followerInbox;
|
||||
inboxes.add(inbox);
|
||||
}
|
||||
}
|
||||
|
||||
this.recipes.filter((recipe): recipe is IDirectRecipe =>
|
||||
// followers recipes have already been processed
|
||||
isDirect(recipe)
|
||||
// check that shared inbox has not been added yet
|
||||
&& !(recipe.to.sharedInbox && inboxes.has(recipe.to.sharedInbox))
|
||||
// check that they actually have an inbox
|
||||
&& recipe.to.inbox != null,
|
||||
)
|
||||
.forEach(recipe => inboxes.add(recipe.to.inbox!));
|
||||
|
||||
// deliver
|
||||
for (const inbox of inboxes) {
|
||||
this.queueService.deliver(this.actor, this.activity, inbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
735
packages/backend/src/core/remote/activitypub/ApInboxService.ts
Normal file
735
packages/backend/src/core/remote/activitypub/ApInboxService.ts
Normal file
@@ -0,0 +1,735 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { CacheableRemoteUser } from '@/models/entities/User.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { ReactionService } from '@/core/ReactionService.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { NotePiningService } from '@/core/NotePiningService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
|
||||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { MessagingService } from '@/core/MessagingService.js';
|
||||
import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApResolverService } from './ApResolverService.js';
|
||||
import { ApAudienceService } from './ApAudienceService.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import { ApQuestionService } from './models/ApQuestionService.js';
|
||||
import type { Resolver } from './ApResolverService.js';
|
||||
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IRead, IReject, IRemove, IUndo, IUpdate } from './type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApInboxService {
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.messagingMessagesRepository)
|
||||
private messagingMessagesRepository: MessagingMessagesRepository,
|
||||
|
||||
@Inject(DI.abuseUserReportsRepository)
|
||||
private abuseUserReportsRepository: AbuseUserReportsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
private metaService: MetaService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private apAudienceService: ApAudienceService,
|
||||
private reactionService: ReactionService,
|
||||
private relayService: RelayService,
|
||||
private notePiningService: NotePiningService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
private noteCreateService: NoteCreateService,
|
||||
private noteDeleteService: NoteDeleteService,
|
||||
private appLockService: AppLockService,
|
||||
private apResolverService: ApResolverService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
private apNoteService: ApNoteService,
|
||||
private apPersonService: ApPersonService,
|
||||
private apQuestionService: ApQuestionService,
|
||||
private queueService: QueueService,
|
||||
private messagingService: MessagingService,
|
||||
) {
|
||||
this.#logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
||||
public async performActivity(actor: CacheableRemoteUser, activity: IObject) {
|
||||
if (isCollectionOrOrderedCollection(activity)) {
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
|
||||
const act = await resolver.resolve(item);
|
||||
try {
|
||||
await this.performOneActivity(actor, act);
|
||||
} catch (err) {
|
||||
if (err instanceof Error || typeof err === 'string') {
|
||||
this.#logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this.performOneActivity(actor, activity);
|
||||
}
|
||||
|
||||
// ついでにリモートユーザーの情報が古かったら更新しておく
|
||||
if (actor.uri) {
|
||||
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||
setImmediate(() => {
|
||||
this.apPersonService.updatePerson(actor.uri!);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async performOneActivity(actor: CacheableRemoteUser, activity: IObject): Promise<void> {
|
||||
if (actor.isSuspended) return;
|
||||
|
||||
if (isCreate(activity)) {
|
||||
await this.#create(actor, activity);
|
||||
} else if (isDelete(activity)) {
|
||||
await this.#delete(actor, activity);
|
||||
} else if (isUpdate(activity)) {
|
||||
await this.#update(actor, activity);
|
||||
} else if (isRead(activity)) {
|
||||
await this.#read(actor, activity);
|
||||
} else if (isFollow(activity)) {
|
||||
await this.#follow(actor, activity);
|
||||
} else if (isAccept(activity)) {
|
||||
await this.#accept(actor, activity);
|
||||
} else if (isReject(activity)) {
|
||||
await this.#reject(actor, activity);
|
||||
} else if (isAdd(activity)) {
|
||||
await this.#add(actor, activity).catch(err => this.#logger.error(err));
|
||||
} else if (isRemove(activity)) {
|
||||
await this.#remove(actor, activity).catch(err => this.#logger.error(err));
|
||||
} else if (isAnnounce(activity)) {
|
||||
await this.#announce(actor, activity);
|
||||
} else if (isLike(activity)) {
|
||||
await this.#like(actor, activity);
|
||||
} else if (isUndo(activity)) {
|
||||
await this.#undo(actor, activity);
|
||||
} else if (isBlock(activity)) {
|
||||
await this.#block(actor, activity);
|
||||
} else if (isFlag(activity)) {
|
||||
await this.#flag(actor, activity);
|
||||
} else {
|
||||
this.#logger.warn(`unrecognized activity type: ${(activity as any).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #follow(actor: CacheableRemoteUser, activity: IFollow): Promise<string> {
|
||||
const followee = await this.apDbResolverService.getUserFromApId(activity.object);
|
||||
|
||||
if (followee == null) {
|
||||
return 'skip: followee not found';
|
||||
}
|
||||
|
||||
if (followee.host != null) {
|
||||
return 'skip: フォローしようとしているユーザーはローカルユーザーではありません';
|
||||
}
|
||||
|
||||
await this.userFollowingService.follow(actor, followee, activity.id);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async #like(actor: CacheableRemoteUser, activity: ILike): Promise<string> {
|
||||
const targetUri = getApId(activity.object);
|
||||
|
||||
const note = await this.apNoteService.fetchNote(targetUri);
|
||||
if (!note) return `skip: target note not found ${targetUri}`;
|
||||
|
||||
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
|
||||
|
||||
return await this.reactionService.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');
|
||||
}
|
||||
|
||||
async #read(actor: CacheableRemoteUser, activity: IRead): Promise<string> {
|
||||
const id = await getApId(activity.object);
|
||||
|
||||
if (!this.utilityService.isSelfHost(this.utilityService.extractDbHost(id))) {
|
||||
return `skip: Read to foreign host (${id})`;
|
||||
}
|
||||
|
||||
const messageId = id.split('/').pop();
|
||||
|
||||
const message = await this.messagingMessagesRepository.findOneBy({ id: messageId });
|
||||
if (message == null) {
|
||||
return 'skip: message not found';
|
||||
}
|
||||
|
||||
if (actor.id !== message.recipientId) {
|
||||
return 'skip: actor is not a message recipient';
|
||||
}
|
||||
|
||||
await this.messagingService.readUserMessagingMessage(message.recipientId!, message.userId, [message.id]);
|
||||
return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`;
|
||||
}
|
||||
|
||||
async #accept(actor: CacheableRemoteUser, activity: IAccept): Promise<string> {
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
this.#logger.info(`Accept: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(err => {
|
||||
this.#logger.error(`Resolution failed: ${err}`);
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (isFollow(object)) return await this.#acceptFollow(actor, object);
|
||||
|
||||
return `skip: Unknown Accept type: ${getApType(object)}`;
|
||||
}
|
||||
|
||||
async #acceptFollow(actor: CacheableRemoteUser, activity: IFollow): Promise<string> {
|
||||
// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
|
||||
|
||||
const follower = await this.apDbResolverService.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 this.relayService.relayAccepted(match[1]);
|
||||
}
|
||||
|
||||
await this.userFollowingService.acceptFollowRequest(actor, follower);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async #add(actor: CacheableRemoteUser, 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 this.apNoteService.resolveNote(activity.object);
|
||||
if (note == null) throw new Error('note not found');
|
||||
await this.notePiningService.addPinned(actor, note.id);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`unknown target: ${activity.target}`);
|
||||
}
|
||||
|
||||
async #announce(actor: CacheableRemoteUser, activity: IAnnounce): Promise<void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
this.#logger.info(`Announce: ${uri}`);
|
||||
|
||||
const targetUri = getApId(activity.object);
|
||||
|
||||
this.#announceNote(actor, activity, targetUri);
|
||||
}
|
||||
|
||||
async #announceNote(actor: CacheableRemoteUser, activity: IAnnounce, targetUri: string): Promise<void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
if (actor.isSuspended) {
|
||||
return;
|
||||
}
|
||||
|
||||
// アナウンス先をブロックしてたら中断
|
||||
const meta = await this.metaService.fetch();
|
||||
if (meta.blockedHosts.includes(this.utilityService.extractDbHost(uri))) return;
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
try {
|
||||
// 既に同じURIを持つものが登録されていないかチェック
|
||||
const exist = await this.apNoteService.fetchNote(uri);
|
||||
if (exist) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Announce対象をresolve
|
||||
let renote;
|
||||
try {
|
||||
renote = await this.apNoteService.resolveNote(targetUri);
|
||||
} catch (err) {
|
||||
// 対象が4xxならスキップ
|
||||
if (err instanceof StatusError) {
|
||||
if (err.isClientError) {
|
||||
this.#logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logger.warn(`Error in announce target ${targetUri} - ${err.statusCode ?? err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) return 'skip: invalid actor for this activity';
|
||||
|
||||
this.#logger.info(`Creating the (Re)Note: ${uri}`);
|
||||
|
||||
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc);
|
||||
|
||||
await this.noteCreateService.create(actor, {
|
||||
createdAt: activity.published ? new Date(activity.published) : null,
|
||||
renote,
|
||||
visibility: activityAudience.visibility,
|
||||
visibleUsers: activityAudience.visibleUsers,
|
||||
uri,
|
||||
});
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
async #block(actor: CacheableRemoteUser, activity: IBlock): Promise<string> {
|
||||
// ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず
|
||||
|
||||
const blockee = await this.apDbResolverService.getUserFromApId(activity.object);
|
||||
|
||||
if (blockee == null) {
|
||||
return 'skip: blockee not found';
|
||||
}
|
||||
|
||||
if (blockee.host != null) {
|
||||
return 'skip: ブロックしようとしているユーザーはローカルユーザーではありません';
|
||||
}
|
||||
|
||||
await this.userBlockingService.block(await this.usersRepository.findOneByOrFail({ id: actor.id }), await this.usersRepository.findOneByOrFail({ id: blockee.id }));
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async #create(actor: CacheableRemoteUser, activity: ICreate): Promise<void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
this.#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 = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.#logger.error(`Resolution failed: ${e}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (isPost(object)) {
|
||||
this.#createNote(resolver, actor, object, false, activity);
|
||||
} else {
|
||||
this.#logger.warn(`Unknown type: ${getApType(object)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #createNote(resolver: Resolver, actor: CacheableRemoteUser, 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 (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
|
||||
return 'skip: host in actor.uri !== note.id';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
try {
|
||||
const exist = await this.apNoteService.fetchNote(note);
|
||||
if (exist) return 'skip: note exists';
|
||||
|
||||
await this.apNoteService.createNote(note, resolver, silent);
|
||||
return 'ok';
|
||||
} catch (e) {
|
||||
if (e instanceof StatusError && e.isClientError) {
|
||||
return `skip ${e.statusCode}`;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
async #delete(actor: CacheableRemoteUser, activity: IDelete): Promise<string> {
|
||||
if ('actor' in activity && actor.uri !== activity.actor) {
|
||||
throw new Error('invalid actor');
|
||||
}
|
||||
|
||||
// 削除対象objectのtype
|
||||
let formerType: string | undefined;
|
||||
|
||||
if (typeof activity.object === 'string') {
|
||||
// typeが不明だけど、どうせ消えてるのでremote resolveしない
|
||||
formerType = undefined;
|
||||
} else {
|
||||
const object = activity.object as IObject;
|
||||
if (isTombstone(object)) {
|
||||
formerType = toSingle(object.formerType);
|
||||
} else {
|
||||
formerType = toSingle(object.type);
|
||||
}
|
||||
}
|
||||
|
||||
const uri = getApId(activity.object);
|
||||
|
||||
// type不明でもactorとobjectが同じならばそれはPersonに違いない
|
||||
if (!formerType && actor.uri === uri) {
|
||||
formerType = 'Person';
|
||||
}
|
||||
|
||||
// それでもなかったらおそらくNote
|
||||
if (!formerType) {
|
||||
formerType = 'Note';
|
||||
}
|
||||
|
||||
if (validPost.includes(formerType)) {
|
||||
return await this.#deleteNote(actor, uri);
|
||||
} else if (validActor.includes(formerType)) {
|
||||
return await this.#deleteActor(actor, uri);
|
||||
} else {
|
||||
return `Unknown type ${formerType}`;
|
||||
}
|
||||
}
|
||||
|
||||
async #deleteActor(actor: CacheableRemoteUser, uri: string): Promise<string> {
|
||||
this.#logger.info(`Deleting the Actor: ${uri}`);
|
||||
|
||||
if (actor.uri !== uri) {
|
||||
return `skip: delete actor ${actor.uri} !== ${uri}`;
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: actor.id });
|
||||
if (user.isDeleted) {
|
||||
this.#logger.info('skip: already deleted');
|
||||
}
|
||||
|
||||
const job = await this.queueService.createDeleteAccountJob(actor);
|
||||
|
||||
await this.usersRepository.update(actor.id, {
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
return `ok: queued ${job.name} ${job.id}`;
|
||||
}
|
||||
|
||||
async #deleteNote(actor: CacheableRemoteUser, uri: string): Promise<string> {
|
||||
this.#logger.info(`Deleting the Note: ${uri}`);
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
try {
|
||||
const note = await this.apDbResolverService.getNoteFromApId(uri);
|
||||
|
||||
if (note == null) {
|
||||
const message = await this.apDbResolverService.getMessageFromApId(uri);
|
||||
if (message == null) return 'message not found';
|
||||
|
||||
if (message.userId !== actor.id) {
|
||||
return '投稿を削除しようとしているユーザーは投稿の作成者ではありません';
|
||||
}
|
||||
|
||||
await this.messagingService.deleteMessage(message);
|
||||
|
||||
return 'ok: message deleted';
|
||||
}
|
||||
|
||||
if (note.userId !== actor.id) {
|
||||
return '投稿を削除しようとしているユーザーは投稿の作成者ではありません';
|
||||
}
|
||||
|
||||
await this.noteDeleteService.delete(actor, note);
|
||||
return 'ok: note deleted';
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
async #flag(actor: CacheableRemoteUser, activity: IFlag): Promise<string> {
|
||||
// objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので
|
||||
// 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する
|
||||
const uris = getApIds(activity.object);
|
||||
|
||||
const userIds = uris.filter(uri => uri.startsWith(this.config.url + '/users/')).map(uri => uri.split('/').pop()!);
|
||||
const users = await this.usersRepository.findBy({
|
||||
id: In(userIds),
|
||||
});
|
||||
if (users.length < 1) return 'skip';
|
||||
|
||||
await this.abuseUserReportsRepository.insert({
|
||||
id: this.idService.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';
|
||||
}
|
||||
|
||||
async #reject(actor: CacheableRemoteUser, activity: IReject): Promise<string> {
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
this.#logger.info(`Reject: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.#logger.error(`Resolution failed: ${e}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (isFollow(object)) return await this.#rejectFollow(actor, object);
|
||||
|
||||
return `skip: Unknown Reject type: ${getApType(object)}`;
|
||||
}
|
||||
|
||||
async #rejectFollow(actor: CacheableRemoteUser, activity: IFollow): Promise<string> {
|
||||
// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
|
||||
|
||||
const follower = await this.apDbResolverService.getUserFromApId(activity.actor);
|
||||
|
||||
if (follower == null) {
|
||||
return 'skip: follower not found';
|
||||
}
|
||||
|
||||
if (!this.userEntityService.isLocalUser(follower)) {
|
||||
return 'skip: follower is not a local user';
|
||||
}
|
||||
|
||||
// relay
|
||||
const match = activity.id?.match(/follow-relay\/(\w+)/);
|
||||
if (match) {
|
||||
return await this.relayService.relayRejected(match[1]);
|
||||
}
|
||||
|
||||
await this.userFollowingService.remoteReject(actor, follower);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async #remove(actor: CacheableRemoteUser, 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 this.apNoteService.resolveNote(activity.object);
|
||||
if (note == null) throw new Error('note not found');
|
||||
await this.notePiningService.removePinned(actor, note.id);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`unknown target: ${activity.target}`);
|
||||
}
|
||||
|
||||
async #undo(actor: CacheableRemoteUser, activity: IUndo): Promise<string> {
|
||||
if ('actor' in activity && actor.uri !== activity.actor) {
|
||||
throw new Error('invalid actor');
|
||||
}
|
||||
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
this.#logger.info(`Undo: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.#logger.error(`Resolution failed: ${e}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (isFollow(object)) return await this.#undoFollow(actor, object);
|
||||
if (isBlock(object)) return await this.#undoBlock(actor, object);
|
||||
if (isLike(object)) return await this.#undoLike(actor, object);
|
||||
if (isAnnounce(object)) return await this.#undoAnnounce(actor, object);
|
||||
if (isAccept(object)) return await this.#undoAccept(actor, object);
|
||||
|
||||
return `skip: unknown object type ${getApType(object)}`;
|
||||
}
|
||||
|
||||
async #undoAccept(actor: CacheableRemoteUser, activity: IAccept): Promise<string> {
|
||||
const follower = await this.apDbResolverService.getUserFromApId(activity.object);
|
||||
if (follower == null) {
|
||||
return 'skip: follower not found';
|
||||
}
|
||||
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: actor.id,
|
||||
});
|
||||
|
||||
if (following) {
|
||||
await this.userFollowingService.unfollow(follower, actor);
|
||||
return 'ok: unfollowed';
|
||||
}
|
||||
|
||||
return 'skip: フォローされていない';
|
||||
}
|
||||
|
||||
async #undoAnnounce(actor: CacheableRemoteUser, activity: IAnnounce): Promise<string> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
uri,
|
||||
userId: actor.id,
|
||||
});
|
||||
|
||||
if (!note) return 'skip: no such Announce';
|
||||
|
||||
await this.noteDeleteService.delete(actor, note);
|
||||
return 'ok: deleted';
|
||||
}
|
||||
|
||||
async #undoBlock(actor: CacheableRemoteUser, activity: IBlock): Promise<string> {
|
||||
const blockee = await this.apDbResolverService.getUserFromApId(activity.object);
|
||||
|
||||
if (blockee == null) {
|
||||
return 'skip: blockee not found';
|
||||
}
|
||||
|
||||
if (blockee.host != null) {
|
||||
return 'skip: ブロック解除しようとしているユーザーはローカルユーザーではありません';
|
||||
}
|
||||
|
||||
await this.userBlockingService.unblock(await this.usersRepository.findOneByOrFail({ id: actor.id }), blockee);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async #undoFollow(actor: CacheableRemoteUser, activity: IFollow): Promise<string> {
|
||||
const followee = await this.apDbResolverService.getUserFromApId(activity.object);
|
||||
if (followee == null) {
|
||||
return 'skip: followee not found';
|
||||
}
|
||||
|
||||
if (followee.host != null) {
|
||||
return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません';
|
||||
}
|
||||
|
||||
const req = await this.followRequestsRepository.findOneBy({
|
||||
followerId: actor.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: actor.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
|
||||
if (req) {
|
||||
await this.userFollowingService.cancelFollowRequest(followee, actor);
|
||||
return 'ok: follow request canceled';
|
||||
}
|
||||
|
||||
if (following) {
|
||||
await this.userFollowingService.unfollow(actor, followee);
|
||||
return 'ok: unfollowed';
|
||||
}
|
||||
|
||||
return 'skip: リクエストもフォローもされていない';
|
||||
}
|
||||
|
||||
async #undoLike(actor: CacheableRemoteUser, activity: ILike): Promise<string> {
|
||||
const targetUri = getApId(activity.object);
|
||||
|
||||
const note = await this.apNoteService.fetchNote(targetUri);
|
||||
if (!note) return `skip: target note not found ${targetUri}`;
|
||||
|
||||
await this.reactionService.delete(actor, note).catch(e => {
|
||||
if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return;
|
||||
throw e;
|
||||
});
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async #update(actor: CacheableRemoteUser, activity: IUpdate): Promise<string> {
|
||||
if ('actor' in activity && actor.uri !== activity.actor) {
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
this.#logger.debug('Update');
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.#logger.error(`Resolution failed: ${e}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (isActor(object)) {
|
||||
await this.apPersonService.updatePerson(actor.uri!, resolver, object);
|
||||
return 'ok: Person updated';
|
||||
} else if (getApType(object) === 'Question') {
|
||||
await this.apQuestionService.updateQuestion(object).catch(err => console.error(err));
|
||||
return 'ok: Question updated';
|
||||
} else {
|
||||
return `skip: Unknown type: ${getApType(object)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type Logger from '@/logger.js';
|
||||
import { RemoteLoggerService } from '@/core/remote/RemoteLoggerService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApLoggerService {
|
||||
public logger: Logger;
|
||||
|
||||
constructor(
|
||||
private remoteLoggerService: RemoteLoggerService,
|
||||
) {
|
||||
this.logger = this.remoteLoggerService.logger.createSubLogger('ap', 'magenta');
|
||||
}
|
||||
}
|
||||
30
packages/backend/src/core/remote/activitypub/ApMfmService.ts
Normal file
30
packages/backend/src/core/remote/activitypub/ApMfmService.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { MfmService } from '@/core/MfmService.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { extractApHashtagObjects } from './models/tag.js';
|
||||
import type { IObject } from './type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApMfmService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private mfmService: MfmService,
|
||||
) {
|
||||
}
|
||||
|
||||
public htmlToMfm(html: string, tag?: IObject | IObject[]) {
|
||||
const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null);
|
||||
|
||||
return this.mfmService.fromHtml(html, hashtagNames);
|
||||
}
|
||||
|
||||
public getNoteHtml(note: Note) {
|
||||
if (!note.text) return '';
|
||||
return this.mfmService.toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
import { createPublicKey } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js';
|
||||
import type { Blocking } from '@/models/entities/Blocking.js';
|
||||
import type { Relay } from '@/models/entities/Relay.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||
import type { Poll } from '@/models/entities/Poll.js';
|
||||
import type { MessagingMessage } from '@/models/entities/MessagingMessage.js';
|
||||
import type { PollVote } from '@/models/entities/PollVote.js';
|
||||
import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js';
|
||||
import { MfmService } from '@/core/MfmService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||
import { LdSignatureService } from './LdSignatureService.js';
|
||||
import { ApMfmService } from './ApMfmService.js';
|
||||
import type { IActivity } from './type.js';
|
||||
import type { IIdentifier } from './models/identifier.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApRendererService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private ldSignatureService: LdSignatureService,
|
||||
private userKeypairStoreService: UserKeypairStoreService,
|
||||
private apMfmService: ApMfmService,
|
||||
private mfmService: MfmService,
|
||||
) {
|
||||
}
|
||||
|
||||
public renderAccept(object: any, user: { id: User['id']; host: null }) {
|
||||
return {
|
||||
type: 'Accept',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
object,
|
||||
};
|
||||
}
|
||||
|
||||
public renderAdd(user: ILocalUser, target: any, object: any) {
|
||||
return {
|
||||
type: 'Add',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
target,
|
||||
object,
|
||||
};
|
||||
}
|
||||
|
||||
public renderAnnounce(object: any, note: Note) {
|
||||
const attributedTo = `${this.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: `${this.config.url}/notes/${note.id}/activity`,
|
||||
actor: `${this.config.url}/users/${note.userId}`,
|
||||
type: 'Announce',
|
||||
published: note.createdAt.toISOString(),
|
||||
to,
|
||||
cc,
|
||||
object,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a block into its ActivityPub representation.
|
||||
*
|
||||
* @param block The block to be rendered. The blockee relation must be loaded.
|
||||
*/
|
||||
public renderBlock(block: Blocking) {
|
||||
if (block.blockee?.uri == null) {
|
||||
throw new Error('renderBlock: missing blockee uri');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Block',
|
||||
id: `${this.config.url}/blocks/${block.id}`,
|
||||
actor: `${this.config.url}/users/${block.blockerId}`,
|
||||
object: block.blockee.uri,
|
||||
};
|
||||
}
|
||||
|
||||
public renderCreate(object: any, note: Note) {
|
||||
const activity = {
|
||||
id: `${this.config.url}/notes/${note.id}/activity`,
|
||||
actor: `${this.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;
|
||||
}
|
||||
|
||||
public renderDelete(object: any, user: { id: User['id']; host: null }) {
|
||||
return {
|
||||
type: 'Delete',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
object,
|
||||
published: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
public renderDocument(file: DriveFile) {
|
||||
return {
|
||||
type: 'Document',
|
||||
mediaType: file.type,
|
||||
url: this.driveFileEntityService.getPublicUrl(file),
|
||||
name: file.comment,
|
||||
};
|
||||
}
|
||||
|
||||
public renderEmoji(emoji: Emoji) {
|
||||
return {
|
||||
id: `${this.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.publicUrl ?? emoji.originalUrl, // ?? emoji.originalUrl してるのは後方互換性のため
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// to anonymise reporters, the reporting actor must be a system user
|
||||
// object has to be a uri or array of uris
|
||||
public renderFlag(user: ILocalUser, object: [string], content: string) {
|
||||
return {
|
||||
type: 'Flag',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
content,
|
||||
object,
|
||||
};
|
||||
}
|
||||
|
||||
public renderFollowRelay(relay: Relay, relayActor: ILocalUser) {
|
||||
const follow = {
|
||||
id: `${this.config.url}/activities/follow-relay/${relay.id}`,
|
||||
type: 'Follow',
|
||||
actor: `${this.config.url}/users/${relayActor.id}`,
|
||||
object: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
};
|
||||
|
||||
return follow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert (local|remote)(Follower|Followee)ID to URL
|
||||
* @param id Follower|Followee ID
|
||||
*/
|
||||
public async renderFollowUser(id: User['id']) {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: id });
|
||||
return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri;
|
||||
}
|
||||
|
||||
public renderFollow(
|
||||
follower: { id: User['id']; host: User['host']; uri: User['host'] },
|
||||
followee: { id: User['id']; host: User['host']; uri: User['host'] },
|
||||
requestId?: string,
|
||||
) {
|
||||
const follow = {
|
||||
id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`,
|
||||
type: 'Follow',
|
||||
actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri,
|
||||
object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri,
|
||||
} as any;
|
||||
|
||||
return follow;
|
||||
}
|
||||
|
||||
public renderHashtag(tag: string) {
|
||||
return {
|
||||
type: 'Hashtag',
|
||||
href: `${this.config.url}/tags/${encodeURIComponent(tag)}`,
|
||||
name: `#${tag}`,
|
||||
};
|
||||
}
|
||||
|
||||
public renderImage(file: DriveFile) {
|
||||
return {
|
||||
type: 'Image',
|
||||
url: this.driveFileEntityService.getPublicUrl(file),
|
||||
sensitive: file.isSensitive,
|
||||
name: file.comment,
|
||||
};
|
||||
}
|
||||
|
||||
public renderKey(user: ILocalUser, key: UserKeypair, postfix?: string) {
|
||||
return {
|
||||
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`,
|
||||
type: 'Key',
|
||||
owner: `${this.config.url}/users/${user.id}`,
|
||||
publicKeyPem: createPublicKey(key.publicKey).export({
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
public async renderLike(noteReaction: NoteReaction, note: Note) {
|
||||
const reaction = noteReaction.reaction;
|
||||
|
||||
const object = {
|
||||
type: 'Like',
|
||||
id: `${this.config.url}/likes/${noteReaction.id}`,
|
||||
actor: `${this.config.url}/users/${noteReaction.userId}`,
|
||||
object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`,
|
||||
content: reaction,
|
||||
_misskey_reaction: reaction,
|
||||
} as any;
|
||||
|
||||
if (reaction.startsWith(':')) {
|
||||
const name = reaction.replace(/:/g, '');
|
||||
const emoji = await this.emojisRepository.findOneBy({
|
||||
name,
|
||||
host: IsNull(),
|
||||
});
|
||||
|
||||
if (emoji) object.tag = [this.renderEmoji(emoji)];
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
public renderMention(mention: User) {
|
||||
return {
|
||||
type: 'Mention',
|
||||
href: this.userEntityService.isRemoteUser(mention) ? mention.uri : `${this.config.url}/users/${(mention as ILocalUser).id}`,
|
||||
name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as ILocalUser).username}`,
|
||||
};
|
||||
}
|
||||
|
||||
public async renderNote(note: Note, dive = true, isTalk = false): Promise<Record<string, unknown>> {
|
||||
const getPromisedFiles = async (ids: string[]) => {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
|
||||
return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[];
|
||||
};
|
||||
|
||||
let inReplyTo;
|
||||
let inReplyToNote: Note | null;
|
||||
|
||||
if (note.replyId) {
|
||||
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
|
||||
|
||||
if (inReplyToNote != null) {
|
||||
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
|
||||
|
||||
if (inReplyToUser != null) {
|
||||
if (inReplyToNote.uri) {
|
||||
inReplyTo = inReplyToNote.uri;
|
||||
} else {
|
||||
if (dive) {
|
||||
inReplyTo = await this.renderNote(inReplyToNote, false);
|
||||
} else {
|
||||
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inReplyTo = null;
|
||||
}
|
||||
|
||||
let quote;
|
||||
|
||||
if (note.renoteId) {
|
||||
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
|
||||
|
||||
if (renote) {
|
||||
quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
const attributedTo = `${this.config.url}/users/${note.userId}`;
|
||||
|
||||
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 this.usersRepository.findBy({
|
||||
id: In(note.mentions),
|
||||
}) : [];
|
||||
|
||||
const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag));
|
||||
const mentionTags = mentionedUsers.map(u => this.renderMention(u));
|
||||
|
||||
const files = await getPromisedFiles(note.fileIds);
|
||||
|
||||
const text = note.text ?? '';
|
||||
let poll: Poll | null = null;
|
||||
|
||||
if (note.hasPoll) {
|
||||
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
}
|
||||
|
||||
let apText = text;
|
||||
|
||||
if (quote) {
|
||||
apText += `\n\nRE: ${quote}`;
|
||||
}
|
||||
|
||||
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
|
||||
|
||||
const content = this.apMfmService.getNoteHtml(Object.assign({}, note, {
|
||||
text: apText,
|
||||
}));
|
||||
|
||||
const emojis = await this.#getEmojis(note.emojis);
|
||||
const apemojis = emojis.map(emoji => this.renderEmoji(emoji));
|
||||
|
||||
const tag = [
|
||||
...hashtagTags,
|
||||
...mentionTags,
|
||||
...apemojis,
|
||||
];
|
||||
|
||||
const asPoll = poll ? {
|
||||
type: 'Question',
|
||||
content: this.apMfmService.getNoteHtml(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: `${this.config.url}/notes/${note.id}`,
|
||||
type: 'Note',
|
||||
attributedTo,
|
||||
summary,
|
||||
content,
|
||||
_misskey_content: text,
|
||||
source: {
|
||||
content: text,
|
||||
mediaType: 'text/x.misskeymarkdown',
|
||||
},
|
||||
_misskey_quote: quote,
|
||||
quoteUrl: quote,
|
||||
published: note.createdAt.toISOString(),
|
||||
to,
|
||||
cc,
|
||||
inReplyTo,
|
||||
attachment: files.map(x => this.renderDocument(x)),
|
||||
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
||||
tag,
|
||||
...asPoll,
|
||||
...asTalk,
|
||||
};
|
||||
}
|
||||
|
||||
public async renderPerson(user: ILocalUser) {
|
||||
const id = `${this.config.url}/users/${user.id}`;
|
||||
const isSystem = !!user.username.match(/\./);
|
||||
|
||||
const [avatar, banner, profile] = await Promise.all([
|
||||
user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : Promise.resolve(undefined),
|
||||
user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : Promise.resolve(undefined),
|
||||
this.userProfilesRepository.findOneByOrFail({ userId: 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 this.#getEmojis(user.emojis);
|
||||
const apemojis = emojis.map(emoji => this.renderEmoji(emoji));
|
||||
|
||||
const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag));
|
||||
|
||||
const tag = [
|
||||
...apemojis,
|
||||
...hashtagTags,
|
||||
];
|
||||
|
||||
const keypair = await this.userKeypairStoreService.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: `${this.config.url}/inbox`,
|
||||
endpoints: { sharedInbox: `${this.config.url}/inbox` },
|
||||
url: `${this.config.url}/@${user.username}`,
|
||||
preferredUsername: user.username,
|
||||
name: user.name,
|
||||
summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null,
|
||||
icon: avatar ? this.renderImage(avatar) : null,
|
||||
image: banner ? this.renderImage(banner) : null,
|
||||
tag,
|
||||
manuallyApprovesFollowers: user.isLocked,
|
||||
discoverable: !!user.isExplorable,
|
||||
publicKey: this.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;
|
||||
}
|
||||
|
||||
public async renderQuestion(user: { id: User['id'] }, note: Note, poll: Poll) {
|
||||
const question = {
|
||||
type: 'Question',
|
||||
id: `${this.config.url}/questions/${note.id}`,
|
||||
actor: `${this.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;
|
||||
}
|
||||
|
||||
public renderRead(user: { id: User['id'] }, message: MessagingMessage) {
|
||||
return {
|
||||
type: 'Read',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
object: message.uri,
|
||||
};
|
||||
}
|
||||
|
||||
public renderReject(object: any, user: { id: User['id'] }) {
|
||||
return {
|
||||
type: 'Reject',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
object,
|
||||
};
|
||||
}
|
||||
|
||||
public renderRemove(user: { id: User['id'] }, target: any, object: any) {
|
||||
return {
|
||||
type: 'Remove',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
target,
|
||||
object,
|
||||
};
|
||||
}
|
||||
|
||||
public renderTombstone(id: string) {
|
||||
return {
|
||||
id,
|
||||
type: 'Tombstone',
|
||||
};
|
||||
}
|
||||
|
||||
public renderUndo(object: any, user: { id: User['id'] }) {
|
||||
if (object == null) return null;
|
||||
const id = typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined;
|
||||
|
||||
return {
|
||||
type: 'Undo',
|
||||
...(id ? { id } : {}),
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
object,
|
||||
published: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
public renderUpdate(object: any, user: { id: User['id'] }) {
|
||||
const activity = {
|
||||
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
type: 'Update',
|
||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
object,
|
||||
published: new Date().toISOString(),
|
||||
} as any;
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: IRemoteUser) {
|
||||
return {
|
||||
id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`,
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
type: 'Create',
|
||||
to: [pollOwner.uri],
|
||||
published: new Date().toISOString(),
|
||||
object: {
|
||||
id: `${this.config.url}/users/${user.id}#votes/${vote.id}`,
|
||||
type: 'Note',
|
||||
attributedTo: `${this.config.url}/users/${user.id}`,
|
||||
to: [pollOwner.uri],
|
||||
inReplyTo: note.uri,
|
||||
name: poll.choices[vote.choice],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public renderActivity(x: any): IActivity | null {
|
||||
if (x == null) return null;
|
||||
|
||||
if (typeof x === 'object' && x.id == null) {
|
||||
x.id = `${this.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: 'https://misskey-hub.net/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);
|
||||
}
|
||||
|
||||
public async attachLdSignature(activity: any, user: { id: User['id']; host: null; }): Promise<IActivity> {
|
||||
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
|
||||
|
||||
const ldSignature = this.ldSignatureService.use();
|
||||
ldSignature.debug = false;
|
||||
activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
public renderOrderedCollectionPage(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: Record<string, unknown>[]) {
|
||||
const page: any = {
|
||||
id,
|
||||
type: 'OrderedCollection',
|
||||
totalItems,
|
||||
};
|
||||
|
||||
if (first) page.first = first;
|
||||
if (last) page.last = last;
|
||||
if (orderedItems) page.orderedItems = orderedItems;
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
async #getEmojis(names: string[]): Promise<Emoji[]> {
|
||||
if (names == null || names.length === 0) return [];
|
||||
|
||||
const emojis = await Promise.all(
|
||||
names.map(name => this.emojisRepository.findOneBy({
|
||||
name,
|
||||
host: IsNull(),
|
||||
})),
|
||||
);
|
||||
|
||||
return emojis.filter(emoji => emoji != null) as Emoji[];
|
||||
}
|
||||
}
|
||||
182
packages/backend/src/core/remote/activitypub/ApRequestService.ts
Normal file
182
packages/backend/src/core/remote/activitypub/ApRequestService.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
|
||||
type Request = {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
type Signed = {
|
||||
request: Request;
|
||||
signingString: string;
|
||||
signature: string;
|
||||
signatureHeader: string;
|
||||
};
|
||||
|
||||
type PrivateKey = {
|
||||
privateKeyPem: string;
|
||||
keyId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApRequestService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private userKeypairStoreService: UserKeypairStoreService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
#createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }): Signed {
|
||||
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: this.#objectAssignWithLcKey({
|
||||
'Date': new Date().toUTCString(),
|
||||
'Host': u.hostname,
|
||||
'Content-Type': 'application/activity+json',
|
||||
'Digest': digestHeader,
|
||||
}, args.additionalHeaders),
|
||||
};
|
||||
|
||||
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
|
||||
|
||||
return {
|
||||
request,
|
||||
signingString: result.signingString,
|
||||
signature: result.signature,
|
||||
signatureHeader: result.signatureHeader,
|
||||
};
|
||||
}
|
||||
|
||||
#createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
|
||||
const u = new URL(args.url);
|
||||
|
||||
const request: Request = {
|
||||
url: u.href,
|
||||
method: 'GET',
|
||||
headers: this.#objectAssignWithLcKey({
|
||||
'Accept': 'application/activity+json, application/ld+json',
|
||||
'Date': new Date().toUTCString(),
|
||||
'Host': new URL(args.url).hostname,
|
||||
}, args.additionalHeaders),
|
||||
};
|
||||
|
||||
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
|
||||
|
||||
return {
|
||||
request,
|
||||
signingString: result.signingString,
|
||||
signature: result.signature,
|
||||
signatureHeader: result.signatureHeader,
|
||||
};
|
||||
}
|
||||
|
||||
#signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
|
||||
const signingString = this.#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 = this.#objectAssignWithLcKey(request.headers, {
|
||||
Signature: signatureHeader,
|
||||
});
|
||||
|
||||
return {
|
||||
request,
|
||||
signingString,
|
||||
signature,
|
||||
signatureHeader,
|
||||
};
|
||||
}
|
||||
|
||||
#genSigningString(request: Request, includeHeaders: string[]): string {
|
||||
request.headers = this.#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');
|
||||
}
|
||||
|
||||
#lcObjectKey(src: Record<string, string>): 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;
|
||||
}
|
||||
|
||||
#objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
|
||||
return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b));
|
||||
}
|
||||
|
||||
public async signedPost(user: { id: User['id'] }, url: string, object: any) {
|
||||
const body = JSON.stringify(object);
|
||||
|
||||
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
|
||||
|
||||
const req = this.#createSignedPost({
|
||||
key: {
|
||||
privateKeyPem: keypair.privateKey,
|
||||
keyId: `${this.config.url}/users/${user.id}#main-key`,
|
||||
},
|
||||
url,
|
||||
body,
|
||||
additionalHeaders: {
|
||||
'User-Agent': this.config.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
await this.httpRequestService.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
|
||||
*/
|
||||
public async signedGet(url: string, user: { id: User['id'] }) {
|
||||
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
|
||||
|
||||
const req = this.#createSignedGet({
|
||||
key: {
|
||||
privateKeyPem: keypair.privateKey,
|
||||
keyId: `${this.config.url}/users/${user.id}#main-key`,
|
||||
},
|
||||
url,
|
||||
additionalHeaders: {
|
||||
'User-Agent': this.config.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await this.httpRequestService.getResponse({
|
||||
url,
|
||||
method: req.request.method,
|
||||
headers: req.request.headers,
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ILocalUser } from '@/models/entities/User.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { isCollectionOrOrderedCollection } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
import { ApRequestService } from './ApRequestService.js';
|
||||
import type { IObject, ICollection, IOrderedCollection } from './type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApResolverService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
@Inject(DI.noteReactionsRepository)
|
||||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private metaService: MetaService,
|
||||
private apRequestService: ApRequestService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
) {
|
||||
}
|
||||
|
||||
public createResolver(): Resolver {
|
||||
return new Resolver(
|
||||
this.config,
|
||||
this.usersRepository,
|
||||
this.notesRepository,
|
||||
this.pollsRepository,
|
||||
this.noteReactionsRepository,
|
||||
this.utilityService,
|
||||
this.instanceActorService,
|
||||
this.metaService,
|
||||
this.apRequestService,
|
||||
this.httpRequestService,
|
||||
this.apRendererService,
|
||||
this.apDbResolverService,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Resolver {
|
||||
private history: Set<string>;
|
||||
private user?: ILocalUser;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
private usersRepository: UsersRepository,
|
||||
private notesRepository: NotesRepository,
|
||||
private pollsRepository: PollsRepository,
|
||||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
private utilityService: UtilityService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private metaService: MetaService,
|
||||
private apRequestService: ApRequestService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
) {
|
||||
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 (value.includes('#')) {
|
||||
// URLs with fragment parts cannot be resolved correctly because
|
||||
// the fragment part does not get transmitted over HTTP(S).
|
||||
// Avoid strange behaviour by not trying to resolve these at all.
|
||||
throw new Error(`cannot resolve URL with fragment: ${value}`);
|
||||
}
|
||||
|
||||
if (this.history.has(value)) {
|
||||
throw new Error('cannot resolve already resolved one');
|
||||
}
|
||||
|
||||
this.history.add(value);
|
||||
|
||||
const host = this.utilityService.extractDbHost(value);
|
||||
if (this.utilityService.isSelfHost(host)) {
|
||||
return await this.resolveLocal(value);
|
||||
}
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
if (meta.blockedHosts.includes(host)) {
|
||||
throw new Error('Instance is blocked');
|
||||
}
|
||||
|
||||
if (this.config.signToActivityPubGet && !this.user) {
|
||||
this.user = await this.instanceActorService.getInstanceActor();
|
||||
}
|
||||
|
||||
const object = (this.user
|
||||
? await this.apRequestService.signedGet(value, this.user)
|
||||
: await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject;
|
||||
|
||||
if (object == null || (
|
||||
Array.isArray(object['@context']) ?
|
||||
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
|
||||
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
|
||||
)) {
|
||||
throw new Error('invalid response');
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
private resolveLocal(url: string): Promise<IObject> {
|
||||
const parsed = this.apDbResolverService.parseUri(url);
|
||||
if (!parsed.local) throw new Error('resolveLocal: not local');
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'notes':
|
||||
return this.notesRepository.findOneByOrFail({ id: parsed.id })
|
||||
.then(note => {
|
||||
if (parsed.rest === 'activity') {
|
||||
// this refers to the create activity and not the note itself
|
||||
return this.apRendererService.renderActivity(this.apRendererService.renderCreate(this.apRendererService.renderNote(note)));
|
||||
} else {
|
||||
return this.apRendererService.renderNote(note);
|
||||
}
|
||||
});
|
||||
case 'users':
|
||||
return this.usersRepository.findOneByOrFail({ id: parsed.id })
|
||||
.then(user => this.apRendererService.renderPerson(user as ILocalUser));
|
||||
case 'questions':
|
||||
// Polls are indexed by the note they are attached to.
|
||||
return Promise.all([
|
||||
this.notesRepository.findOneByOrFail({ id: parsed.id }),
|
||||
this.pollsRepository.findOneByOrFail({ noteId: parsed.id }),
|
||||
])
|
||||
.then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll));
|
||||
case 'likes':
|
||||
return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(reaction =>
|
||||
this.apRendererService.renderActivity(this.apRendererService.renderLike(reaction, { uri: null })));
|
||||
case 'follows':
|
||||
// rest should be <followee id>
|
||||
if (parsed.rest == null || !/^\w+$/.test(parsed.rest)) throw new Error('resolveLocal: invalid follow URI');
|
||||
|
||||
return Promise.all(
|
||||
[parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })),
|
||||
)
|
||||
.then(([follower, followee]) => this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee, url)));
|
||||
default:
|
||||
throw new Error(`resolveLocal: type ${type} unhandled`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import jsonld from 'jsonld';
|
||||
import fetch from 'node-fetch';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { CONTEXTS } from './misc/contexts.js';
|
||||
|
||||
// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017
|
||||
|
||||
@Injectable()
|
||||
export class LdSignatureService {
|
||||
constructor(
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
public use(): LdSignature {
|
||||
return new LdSignature(this.httpRequestService);
|
||||
}
|
||||
}
|
||||
|
||||
class LdSignature {
|
||||
public debug = false;
|
||||
public preLoad = true;
|
||||
public loderTimeout = 10 * 1000;
|
||||
|
||||
constructor(
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
// TODO
|
||||
//timeout: this.loderTimeout,
|
||||
agent: u => u.protocol === 'http:' ? this.httpRequestService.httpAgent : this.httpRequestService.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');
|
||||
}
|
||||
}
|
||||
526
packages/backend/src/core/remote/activitypub/misc/contexts.ts
Normal file
526
packages/backend/src/core/remote/activitypub/misc/contexts.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
/* eslint: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, unknown> = {
|
||||
'https://w3id.org/identity/v1': id_v1,
|
||||
'https://w3id.org/security/v1': security_v1,
|
||||
'https://www.w3.org/ns/activitystreams': activitystreams,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as mfm from 'mfm-js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { toHtml } from '../../../../mfm/to-html.js';
|
||||
|
||||
export default function(note: Note) {
|
||||
if (!note.text) return '';
|
||||
return toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DriveFilesRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { CacheableRemoteUser } from '@/models/entities/User.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { truncate } from '@/misc/truncate.js';
|
||||
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApImageService {
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private apResolverService: ApResolverService,
|
||||
private driveService: DriveService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
) {
|
||||
this.#logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imageを作成します。
|
||||
*/
|
||||
public async createImage(actor: CacheableRemoteUser, value: any): Promise<DriveFile> {
|
||||
// 投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new Error('actor has been suspended');
|
||||
}
|
||||
|
||||
const image = await this.apResolverService.createResolver().resolve(value) as any;
|
||||
|
||||
if (image.url == null) {
|
||||
throw new Error('invalid image: url not privided');
|
||||
}
|
||||
|
||||
this.#logger.info(`Creating the Image: ${image.url}`);
|
||||
|
||||
const instance = await this.metaService.fetch();
|
||||
|
||||
let file = await this.driveService.uploadFromUrl({
|
||||
url: image.url,
|
||||
user: actor,
|
||||
uri: image.url,
|
||||
sensitive: image.sensitive,
|
||||
isLink: !instance.cacheRemoteFiles,
|
||||
comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH),
|
||||
});
|
||||
|
||||
if (file.isLink) {
|
||||
// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、
|
||||
// URLを更新する
|
||||
if (file.url !== image.url) {
|
||||
await this.driveFilesRepository.update({ id: file.id }, {
|
||||
url: image.url,
|
||||
uri: image.url,
|
||||
});
|
||||
|
||||
file = await this.driveFilesRepository.findOneByOrFail({ id: file.id });
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imageを解決します。
|
||||
*
|
||||
* Misskeyに対象のImageが登録されていればそれを返し、そうでなければ
|
||||
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
|
||||
*/
|
||||
public async resolveImage(actor: CacheableRemoteUser, value: any): Promise<DriveFile> {
|
||||
// TODO
|
||||
|
||||
// リモートサーバーからフェッチしてきて登録
|
||||
return await this.createImage(actor, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { toArray, unique } from '@/misc/prelude/array.js';
|
||||
import type { CacheableUser } from '@/models/entities/User.js';
|
||||
import { isMention } from '../type.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
import { ApPersonService } from './ApPersonService.js';
|
||||
import type { IObject, IApMention } from '../type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApMentionService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private apResolverService: ApResolverService,
|
||||
private apPersonService: ApPersonService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async extractApMentions(tags: IObject | IObject[] | null | undefined) {
|
||||
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string));
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
const limit = promiseLimit<CacheableUser | null>(2);
|
||||
const mentionedUsers = (await Promise.all(
|
||||
hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))),
|
||||
)).filter((x): x is CacheableUser => x != null);
|
||||
|
||||
return mentionedUsers;
|
||||
}
|
||||
|
||||
public extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] {
|
||||
if (tags == null) return [];
|
||||
return toArray(tags).filter(isMention);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MessagingMessagesRepository, PollsRepository, EmojisRepository } from '@/models/index.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { CacheableRemoteUser } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { toArray, toSingle, unique } from '@/misc/prelude/array.js';
|
||||
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { PollService } from '@/core/PollService.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { MessagingService } from '@/core/MessagingService.js';
|
||||
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApMfmService } from '../ApMfmService.js';
|
||||
import { ApDbResolverService } from '../ApDbResolverService.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
import { ApAudienceService } from '../ApAudienceService.js';
|
||||
import { ApPersonService } from './ApPersonService.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import { ApMentionService } from './ApMentionService.js';
|
||||
import { ApQuestionService } from './ApQuestionService.js';
|
||||
import { ApImageService } from './ApImageService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IPost } from '../type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApNoteService {
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
@Inject(DI.messagingMessagesRepository)
|
||||
private messagingMessagesRepository: MessagingMessagesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private apMfmService: ApMfmService,
|
||||
private apResolverService: ApResolverService,
|
||||
|
||||
// 循環参照のため / for circular dependency
|
||||
@Inject(forwardRef(() => ApPersonService))
|
||||
private apPersonService: ApPersonService,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private apAudienceService: ApAudienceService,
|
||||
private apMentionService: ApMentionService,
|
||||
private apImageService: ApImageService,
|
||||
private apQuestionService: ApQuestionService,
|
||||
private metaService: MetaService,
|
||||
private messagingService: MessagingService,
|
||||
private appLockService: AppLockService,
|
||||
private pollService: PollService,
|
||||
private noteCreateService: NoteCreateService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
) {
|
||||
this.#logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
||||
public validateNote(object: any, uri: string) {
|
||||
const expectHost = this.utilityService.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 && this.utilityService.extractDbHost(object.id) !== expectHost) {
|
||||
return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
|
||||
}
|
||||
|
||||
if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) {
|
||||
return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Noteをフェッチします。
|
||||
*
|
||||
* Misskeyに対象のNoteが登録されていればそれを返します。
|
||||
*/
|
||||
public async fetchNote(object: string | IObject): Promise<Note | null> {
|
||||
return await this.apDbResolverService.getNoteFromApId(object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Noteを作成します。
|
||||
*/
|
||||
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<Note | null> {
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object: any = await resolver.resolve(value);
|
||||
|
||||
const entryUri = getApId(value);
|
||||
const err = this.validateNote(object, entryUri);
|
||||
if (err) {
|
||||
this.#logger.error(`${err.message}`, {
|
||||
resolver: {
|
||||
history: resolver.getHistory(),
|
||||
},
|
||||
value: value,
|
||||
object: object,
|
||||
});
|
||||
throw new Error('invalid note');
|
||||
}
|
||||
|
||||
const note: IPost = object;
|
||||
|
||||
this.#logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||
|
||||
this.#logger.info(`Creating the Note: ${note.id}`);
|
||||
|
||||
// 投稿者をフェッチ
|
||||
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as CacheableRemoteUser;
|
||||
|
||||
// 投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new Error('actor has been suspended');
|
||||
}
|
||||
|
||||
const noteAudience = await this.apAudienceService.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 isMessaging = note._misskey_talk && visibility === 'specified';
|
||||
|
||||
const apMentions = await this.apMentionService.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(() => this.apImageService.resolveImage(actor, x)) as Promise<DriveFile>)))
|
||||
.filter(image => image != null)
|
||||
: [];
|
||||
|
||||
// リプライ
|
||||
const reply: Note | null = note.inReplyTo
|
||||
? await this.resolveNote(note.inReplyTo, resolver).then(x => {
|
||||
if (x == null) {
|
||||
this.#logger.warn('Specified inReplyTo, but nout found');
|
||||
throw new Error('inReplyTo not found');
|
||||
} else {
|
||||
return x;
|
||||
}
|
||||
}).catch(async err => {
|
||||
// トークだったらinReplyToのエラーは無視
|
||||
const uri = getApId(note.inReplyTo);
|
||||
if (uri.startsWith(this.config.url + '/')) {
|
||||
const id = uri.split('/').pop();
|
||||
const talk = await this.messagingMessagesRepository.findOneBy({ id });
|
||||
if (talk) {
|
||||
isMessaging = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
this.#logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
|
||||
throw err;
|
||||
})
|
||||
: 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 this.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;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
// vote
|
||||
if (reply && reply.hasPoll) {
|
||||
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
||||
|
||||
const tryCreateVote = async (name: string, index: number): Promise<null> => {
|
||||
if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) {
|
||||
this.#logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
|
||||
} else if (index >= 0) {
|
||||
this.#logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
|
||||
await this.pollService.vote(actor, reply, index);
|
||||
|
||||
// リモートフォロワーにUpdate配信
|
||||
this.pollService.deliverQuestionUpdate(reply.id);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (note.name) {
|
||||
return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name));
|
||||
}
|
||||
}
|
||||
|
||||
const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => {
|
||||
this.#logger.info(`extractEmojis: ${e}`);
|
||||
return [] as Emoji[];
|
||||
});
|
||||
|
||||
const apEmojis = emojis.map(emoji => emoji.name);
|
||||
|
||||
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
|
||||
|
||||
if (isMessaging) {
|
||||
for (const recipient of visibleUsers) {
|
||||
await this.messagingService.createMessage(actor, recipient, undefined, text ?? undefined, (files && files.length > 0) ? files[0] : null, object.id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.noteCreateService.create(actor, {
|
||||
createdAt: note.published ? new Date(note.published) : null,
|
||||
files,
|
||||
reply,
|
||||
renote: quote,
|
||||
name: note.name,
|
||||
cw,
|
||||
text,
|
||||
localOnly: false,
|
||||
visibility,
|
||||
visibleUsers,
|
||||
apMentions,
|
||||
apHashtags,
|
||||
apEmojis,
|
||||
poll,
|
||||
uri: note.id,
|
||||
url: getOneApHrefNullable(note.url),
|
||||
}, silent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Noteを解決します。
|
||||
*
|
||||
* Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ
|
||||
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
|
||||
*/
|
||||
public async 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 this.metaService.fetch();
|
||||
if (meta.blockedHosts.includes(this.utilityService.extractDbHost(uri))) throw { statusCode: 451 };
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
try {
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
const exist = await this.fetchNote(uri);
|
||||
|
||||
if (exist) {
|
||||
return exist;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
if (uri.startsWith(this.config.url)) {
|
||||
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
|
||||
}
|
||||
|
||||
// リモートサーバーからフェッチしてきて登録
|
||||
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
||||
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
||||
return await this.createNote(uri, resolver, true);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public async extractEmojis(tags: IObject | IObject[], host: string): Promise<Emoji[]> {
|
||||
host = this.utilityService.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 this.emojisRepository.findOneBy({
|
||||
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.originalUrl)
|
||||
) {
|
||||
await this.emojisRepository.update({
|
||||
host,
|
||||
name,
|
||||
}, {
|
||||
uri: tag.id,
|
||||
originalUrl: tag.icon!.url,
|
||||
publicUrl: tag.icon!.url,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return await this.emojisRepository.findOneBy({
|
||||
host,
|
||||
name,
|
||||
}) as Emoji;
|
||||
}
|
||||
|
||||
return exists;
|
||||
}
|
||||
|
||||
this.#logger.info(`register emoji host=${host}, name=${name}`);
|
||||
|
||||
return await this.emojisRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
host,
|
||||
name,
|
||||
uri: tag.id,
|
||||
originalUrl: tag.icon!.url,
|
||||
publicUrl: tag.icon!.url,
|
||||
updatedAt: new Date(),
|
||||
aliases: [],
|
||||
} as Partial<Emoji>).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { CacheableUser, IRemoteUser } from '@/models/entities/User.js';
|
||||
import { User } from '@/models/entities/User.js';
|
||||
import { truncate } from '@/misc/truncate.js';
|
||||
import type { UserCacheService } from '@/core/UserCacheService.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import type { IdService } from '@/core/IdService.js';
|
||||
import type { MfmService } from '@/core/MfmService.js';
|
||||
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import type { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||
import { UserProfile } from '@/models/entities/UserProfile.js';
|
||||
import { UserPublickey } from '@/models/entities/UserPublickey.js';
|
||||
import type UsersChart from '@/core/chart/charts/users.js';
|
||||
import type InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import type { HashtagService } from '@/core/HashtagService.js';
|
||||
import { UserNotePining } from '@/models/entities/UserNotePining.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import type { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { ApNoteService } from './ApNoteService.js';
|
||||
import type { ApMfmService } from '../ApMfmService.js';
|
||||
import type { ApResolverService, Resolver } from '../ApResolverService.js';
|
||||
import type { ApLoggerService } from '../ApLoggerService.js';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type { ApImageService } from './ApImageService.js';
|
||||
import type { IActor, IObject, IApPropertyValue } from '../type.js';
|
||||
|
||||
const nameLength = 128;
|
||||
const summaryLength = 2048;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApPersonService implements OnModuleInit {
|
||||
private utilityService: UtilityService;
|
||||
private userEntityService: UserEntityService;
|
||||
private idService: IdService;
|
||||
private globalEventService: GlobalEventService;
|
||||
private federatedInstanceService: FederatedInstanceService;
|
||||
private fetchInstanceMetadataService: FetchInstanceMetadataService;
|
||||
private userCacheService: UserCacheService;
|
||||
private apResolverService: ApResolverService;
|
||||
private apNoteService: ApNoteService;
|
||||
private apImageService: ApImageService;
|
||||
private apMfmService: ApMfmService;
|
||||
private mfmService: MfmService;
|
||||
private hashtagService: HashtagService;
|
||||
private usersChart: UsersChart;
|
||||
private instanceChart: InstanceChart;
|
||||
private apLoggerService: ApLoggerService;
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userPublickeysRepository)
|
||||
private userPublickeysRepository: UserPublickeysRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
//private utilityService: UtilityService,
|
||||
//private userEntityService: UserEntityService,
|
||||
//private idService: IdService,
|
||||
//private globalEventService: GlobalEventService,
|
||||
//private federatedInstanceService: FederatedInstanceService,
|
||||
//private fetchInstanceMetadataService: FetchInstanceMetadataService,
|
||||
//private userCacheService: UserCacheService,
|
||||
//private apResolverService: ApResolverService,
|
||||
//private apNoteService: ApNoteService,
|
||||
//private apImageService: ApImageService,
|
||||
//private apMfmService: ApMfmService,
|
||||
//private mfmService: MfmService,
|
||||
//private hashtagService: HashtagService,
|
||||
//private usersChart: UsersChart,
|
||||
//private instanceChart: InstanceChart,
|
||||
//private apLoggerService: ApLoggerService,
|
||||
) {
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.utilityService = this.moduleRef.get('UtilityService');
|
||||
this.userEntityService = this.moduleRef.get('UserEntityService');
|
||||
this.idService = this.moduleRef.get('IdService');
|
||||
this.globalEventService = this.moduleRef.get('GlobalEventService');
|
||||
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
||||
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
|
||||
this.userCacheService = this.moduleRef.get('UserCacheService');
|
||||
this.apResolverService = this.moduleRef.get('ApResolverService');
|
||||
this.apNoteService = this.moduleRef.get('ApNoteService');
|
||||
this.apImageService = this.moduleRef.get('ApImageService');
|
||||
this.apMfmService = this.moduleRef.get('ApMfmService');
|
||||
this.mfmService = this.moduleRef.get('MfmService');
|
||||
this.hashtagService = this.moduleRef.get('HashtagService');
|
||||
this.usersChart = this.moduleRef.get('UsersChart');
|
||||
this.instanceChart = this.moduleRef.get('InstanceChart');
|
||||
this.apLoggerService = this.moduleRef.get('ApLoggerService');
|
||||
this.#logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and convert to actor object
|
||||
* @param x Fetched object
|
||||
* @param uri Fetch target URI
|
||||
*/
|
||||
#validateActor(x: IObject, uri: string): IActor {
|
||||
const expectHost = this.utilityService.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}'`);
|
||||
}
|
||||
|
||||
if (!(typeof x.id === 'string' && x.id.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong id');
|
||||
}
|
||||
|
||||
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong inbox');
|
||||
}
|
||||
|
||||
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
||||
throw new Error('invalid Actor: wrong username');
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (x.name) {
|
||||
if (!(typeof x.name === 'string' && x.name.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong name');
|
||||
}
|
||||
x.name = truncate(x.name, nameLength);
|
||||
}
|
||||
if (x.summary) {
|
||||
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong summary');
|
||||
}
|
||||
x.summary = truncate(x.summary, summaryLength);
|
||||
}
|
||||
|
||||
const idHost = this.utilityService.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 = this.utilityService.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が登録されていればそれを返します。
|
||||
*/
|
||||
public async fetchPerson(uri: string, resolver?: Resolver): Promise<CacheableUser | null> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
const cached = this.userCacheService.uriPersonCache.get(uri);
|
||||
if (cached) return cached;
|
||||
|
||||
// URIがこのサーバーを指しているならデータベースからフェッチ
|
||||
if (uri.startsWith(this.config.url + '/')) {
|
||||
const id = uri.split('/').pop();
|
||||
const u = await this.usersRepository.findOneBy({ id });
|
||||
if (u) this.userCacheService.uriPersonCache.set(uri, u);
|
||||
return u;
|
||||
}
|
||||
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
const exist = await this.usersRepository.findOneBy({ uri });
|
||||
|
||||
if (exist) {
|
||||
this.userCacheService.uriPersonCache.set(uri, exist);
|
||||
return exist;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Personを作成します。
|
||||
*/
|
||||
public async createPerson(uri: string, resolver?: Resolver): Promise<User> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
if (uri.startsWith(this.config.url)) {
|
||||
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
||||
}
|
||||
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(uri) as any;
|
||||
|
||||
const person = this.#validateActor(object, uri);
|
||||
|
||||
this.#logger.info(`Creating the Person: ${person.id}`);
|
||||
|
||||
const host = this.utilityService.toPuny(new URL(object.id).hostname);
|
||||
|
||||
const { fields } = this.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 this.db.transaction(async transactionalEntityManager => {
|
||||
user = await transactionalEntityManager.save(new User({
|
||||
id: this.idService.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,
|
||||
showTimelineReplies: false,
|
||||
})) as IRemoteUser;
|
||||
|
||||
await transactionalEntityManager.save(new UserProfile({
|
||||
userId: user.id,
|
||||
description: person.summary ? this.apMfmService.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 this.usersRepository.findOneBy({
|
||||
uri: person.id,
|
||||
});
|
||||
|
||||
if (u) {
|
||||
user = u as IRemoteUser;
|
||||
} else {
|
||||
throw new Error('already registered');
|
||||
}
|
||||
} else {
|
||||
this.#logger.error(e instanceof Error ? e : new Error(e as string));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Register host
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
||||
this.instanceChart.newUser(i.host);
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
});
|
||||
|
||||
this.usersChart.update(user!, true);
|
||||
|
||||
// ハッシュタグ更新
|
||||
this.hashtagService.updateUsertags(user!, tags);
|
||||
|
||||
//#region アバターとヘッダー画像をフェッチ
|
||||
const [avatar, banner] = await Promise.all([
|
||||
person.icon,
|
||||
person.image,
|
||||
].map(img =>
|
||||
img == null
|
||||
? Promise.resolve(null)
|
||||
: this.apImageService.resolveImage(user!, img).catch(() => null),
|
||||
));
|
||||
|
||||
const avatarId = avatar ? avatar.id : null;
|
||||
const bannerId = banner ? banner.id : null;
|
||||
|
||||
await this.usersRepository.update(user!.id, {
|
||||
avatarId,
|
||||
bannerId,
|
||||
});
|
||||
|
||||
user!.avatarId = avatarId;
|
||||
user!.bannerId = bannerId;
|
||||
//#endregion
|
||||
|
||||
//#region カスタム絵文字取得
|
||||
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
|
||||
this.#logger.info(`extractEmojis: ${err}`);
|
||||
return [] as Emoji[];
|
||||
});
|
||||
|
||||
const emojiNames = emojis.map(emoji => emoji.name);
|
||||
|
||||
await this.usersRepository.update(user!.id, {
|
||||
emojis: emojiNames,
|
||||
});
|
||||
//#endregion
|
||||
|
||||
await this.updateFeatured(user!.id).catch(err => this.#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をせずに更新に利用します)
|
||||
*/
|
||||
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise<void> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (uri.startsWith(this.config.url + '/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const exist = await this.usersRepository.findOneBy({ uri }) as IRemoteUser;
|
||||
|
||||
if (exist == null) {
|
||||
return;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = hint ?? await resolver.resolve(uri);
|
||||
|
||||
const person = this.#validateActor(object, uri);
|
||||
|
||||
this.#logger.info(`Updating the Person: ${person.id}`);
|
||||
|
||||
// アバターとヘッダー画像をフェッチ
|
||||
const [avatar, banner] = await Promise.all([
|
||||
person.icon,
|
||||
person.image,
|
||||
].map(img =>
|
||||
img == null
|
||||
? Promise.resolve(null)
|
||||
: this.apImageService.resolveImage(exist, img).catch(() => null),
|
||||
));
|
||||
|
||||
// カスタム絵文字取得
|
||||
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => {
|
||||
this.#logger.info(`extractEmojis: ${e}`);
|
||||
return [] as Emoji[];
|
||||
});
|
||||
|
||||
const emojiNames = emojis.map(emoji => emoji.name);
|
||||
|
||||
const { fields } = this.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;
|
||||
}
|
||||
|
||||
if (banner) {
|
||||
updates.bannerId = banner.id;
|
||||
}
|
||||
|
||||
// Update user
|
||||
await this.usersRepository.update(exist.id, updates);
|
||||
|
||||
if (person.publicKey) {
|
||||
await this.userPublickeysRepository.update({ userId: exist.id }, {
|
||||
keyId: person.publicKey.id,
|
||||
keyPem: person.publicKey.publicKeyPem,
|
||||
});
|
||||
}
|
||||
|
||||
await this.userProfilesRepository.update({ userId: exist.id }, {
|
||||
url: getOneApHrefNullable(person.url),
|
||||
fields,
|
||||
description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
|
||||
birthday: bday ? bday[0] : null,
|
||||
location: person['vcard:Address'] ?? null,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id });
|
||||
|
||||
// ハッシュタグ更新
|
||||
this.hashtagService.updateUsertags(exist, tags);
|
||||
|
||||
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
|
||||
await this.followingsRepository.update({
|
||||
followerId: exist.id,
|
||||
}, {
|
||||
followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
|
||||
});
|
||||
|
||||
await this.updateFeatured(exist.id).catch(err => this.#logger.error(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Personを解決します。
|
||||
*
|
||||
* Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ
|
||||
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
|
||||
*/
|
||||
public async resolvePerson(uri: string, resolver?: Resolver): Promise<CacheableUser> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
const exist = await this.fetchPerson(uri);
|
||||
|
||||
if (exist) {
|
||||
return exist;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// リモートサーバーからフェッチしてきて登録
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
return await this.createPerson(uri, resolver);
|
||||
}
|
||||
|
||||
public 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: this.mfmService.fromHtml(attachment.value),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, services };
|
||||
}
|
||||
|
||||
public async updateFeatured(userId: User['id']) {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: userId });
|
||||
if (!this.userEntityService.isRemoteUser(user)) return;
|
||||
if (!user.featured) return;
|
||||
|
||||
this.#logger.info(`Updating the featured: ${user.uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
// 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(() => this.apNoteService.resolveNote(item, resolver))));
|
||||
|
||||
await this.db.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: this.idService.genId(new Date(Date.now() + td)),
|
||||
createdAt: new Date(),
|
||||
userId: user.id,
|
||||
noteId: note!.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotesRepository, PollsRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { IPoll } from '@/models/entities/Poll.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { isQuestion } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IQuestion } from '../type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApQuestionService {
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
private apResolverService: ApResolverService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
) {
|
||||
this.#logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
||||
public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> {
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
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
|
||||
*/
|
||||
public async updateQuestion(value: any) {
|
||||
const uri = typeof value === 'string' ? value : value.id;
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const note = await this.notesRepository.findOneBy({ uri });
|
||||
if (note == null) throw new Error('Question is not registed');
|
||||
|
||||
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
if (poll == null) throw new Error('Question is not registed');
|
||||
//#endregion
|
||||
|
||||
// resolve new Question object
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
const question = await resolver.resolve(value) as IQuestion;
|
||||
this.#logger.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 this.pollsRepository.update({ noteId: note.id }, {
|
||||
votes: poll.votes,
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type IIcon = {
|
||||
type: string;
|
||||
mediaType?: string;
|
||||
url?: string;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type IIdentifier = {
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
19
packages/backend/src/core/remote/activitypub/models/tag.ts
Normal file
19
packages/backend/src/core/remote/activitypub/models/tag.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { isHashtag } from '../type.js';
|
||||
import type { IObject, IApHashtag } from '../type.js';
|
||||
|
||||
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);
|
||||
}
|
||||
295
packages/backend/src/core/remote/activitypub/type.ts
Normal file
295
packages/backend/src/core/remote/activitypub/type.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
export type obj = { [x: string]: any };
|
||||
export type ApObject = IObject | string | (IObject | string)[];
|
||||
|
||||
export interface IObject {
|
||||
'@context': string | 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';
|
||||
source?: {
|
||||
content: string;
|
||||
mediaType: string;
|
||||
};
|
||||
_misskey_quote?: string;
|
||||
quoteUrl?: string;
|
||||
_misskey_talk: boolean;
|
||||
}
|
||||
|
||||
export interface IQuestion extends IObject {
|
||||
type: 'Note' | 'Question';
|
||||
source?: {
|
||||
content: string;
|
||||
mediaType: 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';
|
||||
Reference in New Issue
Block a user