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