From 8e13f6a7e0cadfccab34e94cd81e6a136ba90806 Mon Sep 17 00:00:00 2001
From: MeiMei <30769358+mei23@users.noreply.github.com>
Date: Fri, 3 Apr 2020 22:51:38 +0900
Subject: [PATCH] =?UTF-8?q?AP=E3=83=A1=E3=83=B3=E3=82=B7=E3=83=A7=E3=83=B3?=
 =?UTF-8?q?=E3=81=AFaudience=E3=81=98=E3=82=83=E3=81=AA=E3=81=8F=E3=81=A6t?=
 =?UTF-8?q?ag=E3=82=92=E5=8F=82=E7=85=A7=E3=81=99=E3=82=8B=E3=81=AA?=
 =?UTF-8?q?=E3=81=A9=20(#6128)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* APメンションはaudienceじゃなくてtagを参照するなど

* AP/tag/Mentionではurlじゃなくてuriを提示する

* createPersonでaliasが入力された場合に対応

* AP HTMLパースでMention/Hashtag判定にtagを使うように

* fix

* indent

* use hashtag name

* fix

* URLエンコード不要だったら<>を使わないの条件が消えたたのを修正
---
 src/mfm/fromHtml.ts                        | 12 ++---
 src/remote/activitypub/misc/html-to-mfm.ts |  9 ++++
 src/remote/activitypub/models/mention.ts   | 24 ++++++++++
 src/remote/activitypub/models/note.ts      | 20 ++++----
 src/remote/activitypub/models/person.ts    | 53 +++++++++++-----------
 src/remote/activitypub/models/tag.ts       | 30 +++++-------
 src/remote/activitypub/renderer/mention.ts |  2 +-
 src/remote/activitypub/type.ts             | 42 ++++++++++++++++-
 8 files changed, 128 insertions(+), 64 deletions(-)
 create mode 100644 src/remote/activitypub/misc/html-to-mfm.ts
 create mode 100644 src/remote/activitypub/models/mention.ts

diff --git a/src/mfm/fromHtml.ts b/src/mfm/fromHtml.ts
index 0ffae014a..8c6ca337e 100644
--- a/src/mfm/fromHtml.ts
+++ b/src/mfm/fromHtml.ts
@@ -1,7 +1,7 @@
 import { parseFragment, DefaultTreeDocumentFragment } from 'parse5';
 import { urlRegex } from './prelude';
 
-export function fromHtml(html: string): string {
+export function fromHtml(html: string, hashtagNames?: string[]): string {
 	const dom = parseFragment(html) as DefaultTreeDocumentFragment;
 
 	let text = '';
@@ -36,12 +36,10 @@ export function fromHtml(html: string): string {
 				const txt = getText(node);
 				const rel = node.attrs.find((x: any) => x.name == 'rel');
 				const href = node.attrs.find((x: any) => x.name == 'href');
-				const _class = node.attrs.find((x: any) => x.name == 'class');
-				const isHashtag = rel?.value?.match('tag') || _class?.value?.match('hashtag');
 
-				// ハッシュタグ / hrefがない / txtがURL
-				if (isHashtag || !href || href.value == txt) {
-					text += isHashtag || txt.match(urlRegex) ? txt : `<${txt}>`;
+				// ハッシュタグ
+				if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
+					text += txt;
 				// メンション
 				} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
 					const part = txt.split('@');
@@ -56,7 +54,7 @@ export function fromHtml(html: string): string {
 					}
 				// その他
 				} else {
-					text += `[${txt}](${href.value})`;
+					text += (!href || (txt === href.value && txt.match(urlRegex))) ? txt : `[${txt}](${href.value})`;
 				}
 				break;
 
diff --git a/src/remote/activitypub/misc/html-to-mfm.ts b/src/remote/activitypub/misc/html-to-mfm.ts
new file mode 100644
index 000000000..5d3cf0b77
--- /dev/null
+++ b/src/remote/activitypub/misc/html-to-mfm.ts
@@ -0,0 +1,9 @@
+import { IObject } from '../type';
+import { extractApHashtagObjects } from '../models/tag';
+import { fromHtml } from '../../../mfm/fromHtml';
+
+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);
+}
diff --git a/src/remote/activitypub/models/mention.ts b/src/remote/activitypub/models/mention.ts
new file mode 100644
index 000000000..5d10328ef
--- /dev/null
+++ b/src/remote/activitypub/models/mention.ts
@@ -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);
+}
diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts
index 55b5440e6..1e633704d 100644
--- a/src/remote/activitypub/models/note.ts
+++ b/src/remote/activitypub/models/note.ts
@@ -6,9 +6,9 @@ import post from '../../../services/note/create';
 import { resolvePerson, updatePerson } from './person';
 import { resolveImage } from './image';
 import { IRemoteUser } from '../../../models/entities/user';
-import { fromHtml } from '../../../mfm/fromHtml';
-import { ITag, extractHashtags } from './tag';
-import { unique } from '../../../prelude/array';
+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';
@@ -17,7 +17,7 @@ import { deliverQuestionUpdate } from '../../../services/note/polls/update';
 import { extractDbHost, toPuny } from '../../../misc/convert-host';
 import { Notes, Emojis, Polls, MessagingMessages } from '../../../models';
 import { Note } from '../../../models/entities/note';
-import { IObject, getOneApId, getApId, validPost, IPost } from '../type';
+import { IObject, getOneApId, getApId, validPost, IPost, isEmoji } from '../type';
 import { Emoji } from '../../../models/entities/emoji';
 import { genId } from '../../../misc/gen-id';
 import { fetchMeta } from '../../../misc/fetch-meta';
@@ -25,6 +25,7 @@ import { ensure } from '../../../prelude/ensure';
 import { getApLock } from '../../../misc/app-lock';
 import { createMessage } from '../../../services/messages/create';
 import { parseAudience } from '../audience';
+import { extractApMentions } from './mention';
 
 const logger = apLogger;
 
@@ -113,7 +114,6 @@ export async function createNote(value: string | IObject, resolver?: Resolver, s
 	const noteAudience = await parseAudience(actor, note.to, note.cc);
 	let visibility = noteAudience.visibility;
 	const visibleUsers = noteAudience.visibleUsers;
-	const apMentions = noteAudience.mentionedUsers;
 
 	// Audience (to, cc) が指定されてなかった場合
 	if (visibility === 'specified' && visibleUsers.length === 0) {
@@ -125,7 +125,8 @@ export async function createNote(value: string | IObject, resolver?: Resolver, s
 
 	let isTalk = note._misskey_talk && visibility === 'specified';
 
-	const apHashtags = await extractHashtags(note.tag);
+	const apMentions = await extractApMentions(note.tag);
+	const apHashtags = await extractApHashtags(note.tag);
 
 	// 添付ファイル
 	// TODO: attachmentは必ずしもImageではない
@@ -210,7 +211,7 @@ export async function createNote(value: string | IObject, resolver?: Resolver, s
 	const cw = note.summary === '' ? null : note.summary;
 
 	// テキストのパース
-	const text = note._misskey_content || (note.content ? fromHtml(note.content) : null);
+	const text = note._misskey_content || (note.content ? htmlToMfm(note.content, note.tag) : null);
 
 	// vote
 	if (reply && reply.hasPoll) {
@@ -319,15 +320,16 @@ export async function resolveNote(value: string | IObject, resolver?: Resolver):
 	}
 }
 
-export async function extractEmojis(tags: ITag[], host: string): Promise<Emoji[]> {
+export async function extractEmojis(tags: IObject | IObject[], host: string): Promise<Emoji[]> {
 	host = toPuny(host);
 
 	if (!tags) return [];
 
-	const eomjiTags = tags.filter(tag => tag.type === 'Emoji' && tag.icon && tag.icon.url && tag.name);
+	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,
diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts
index f9ffa4d77..5936cee32 100644
--- a/src/remote/activitypub/models/person.ts
+++ b/src/remote/activitypub/models/person.ts
@@ -3,12 +3,12 @@ import * as promiseLimit from 'promise-limit';
 import config from '../../../config';
 import Resolver from '../resolver';
 import { resolveImage } from './image';
-import { isCollectionOrOrderedCollection, isCollection, IPerson, getApId } from '../type';
+import { isCollectionOrOrderedCollection, isCollection, IPerson, getApId, IObject, isPropertyValue, IApPropertyValue } from '../type';
 import { fromHtml } from '../../../mfm/fromHtml';
+import { htmlToMfm } from '../misc/html-to-mfm';
 import { resolveNote, extractEmojis } from './note';
 import { registerOrFetchInstanceDoc } from '../../../services/register-or-fetch-instance-doc';
-import { ITag, extractHashtags } from './tag';
-import { IIdentifier } from './identifier';
+import { extractApHashtags } from './tag';
 import { apLogger } from '../logger';
 import { Note } from '../../../models/entities/note';
 import { updateUsertags } from '../../../services/update-hashtag';
@@ -134,7 +134,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us
 
 	const { fields } = analyzeAttachments(person.attachment || []);
 
-	const tags = extractHashtags(person.tag).map(tag => tag.toLowerCase()).splice(0, 32);
+	const tags = extractApHashtags(person.tag).map(tag => tag.toLowerCase()).splice(0, 32);
 
 	const isBot = object.type == 'Service';
 
@@ -165,7 +165,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us
 
 			await transactionalEntityManager.save(new UserProfile({
 				userId: user.id,
-				description: person.summary ? fromHtml(person.summary) : null,
+				description: person.summary ? htmlToMfm(person.summary, person.tag) : null,
 				url: person.url,
 				fields,
 				userHost: host
@@ -180,11 +180,20 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us
 	} catch (e) {
 		// duplicate key error
 		if (isDuplicateKeyValueError(e)) {
-			throw new Error('already registered');
-		}
+			// /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応
+			const u = await Users.findOne({
+				uri: person.id
+			});
 
-		logger.error(e);
-		throw e;
+			if (u) {
+				user = u as IRemoteUser;
+			} else {
+				throw new Error('already registered');
+			}
+		} else {
+			logger.error(e);
+			throw e;
+		}
 	}
 
 	// Register host
@@ -308,7 +317,7 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint
 
 	const { fields } = analyzeAttachments(person.attachment || []);
 
-	const tags = extractHashtags(person.tag).map(tag => tag.toLowerCase()).splice(0, 32);
+	const tags = extractApHashtags(person.tag).map(tag => tag.toLowerCase()).splice(0, 32);
 
 	const updates = {
 		lastFetchedAt: new Date(),
@@ -346,7 +355,7 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint
 	await UserProfiles.update({ userId: exist.id }, {
 		url: person.url,
 		fields,
-		description: person.summary ? fromHtml(person.summary) : null,
+		description: person.summary ? htmlToMfm(person.summary, person.tag) : null,
 	});
 
 	// ハッシュタグ更新
@@ -384,16 +393,6 @@ export async function resolvePerson(uri: string, resolver?: Resolver): Promise<U
 	return await createPerson(uri, resolver);
 }
 
-const isPropertyValue = (x: {
-		type: string,
-		name?: string,
-		value?: string
-	}) =>
-		x &&
-		x.type === 'PropertyValue' &&
-		typeof x.name === 'string' &&
-		typeof x.value === 'string';
-
 const services: {
 		[x: string]: (id: string, username: string) => any
 	} = {
@@ -409,7 +408,7 @@ const $discord = (id: string, name: string) => {
 	return { id, username, discriminator };
 };
 
-function addService(target: { [x: string]: any }, source: IIdentifier) {
+function addService(target: { [x: string]: any }, source: IApPropertyValue) {
 	const service = services[source.name];
 
 	if (typeof source.value !== 'string')
@@ -421,7 +420,7 @@ function addService(target: { [x: string]: any }, source: IIdentifier) {
 		target[source.name.split(':')[2]] = service(id, username);
 }
 
-export function analyzeAttachments(attachments: ITag[]) {
+export function analyzeAttachments(attachments: IObject | IObject[] | undefined) {
 	const fields: {
 		name: string,
 		value: string
@@ -430,12 +429,12 @@ export function analyzeAttachments(attachments: ITag[]) {
 
 	if (Array.isArray(attachments)) {
 		for (const attachment of attachments.filter(isPropertyValue)) {
-			if (isPropertyValue(attachment.identifier!)) {
-				addService(services, attachment.identifier!);
+			if (isPropertyValue(attachment.identifier)) {
+				addService(services, attachment.identifier);
 			} else {
 				fields.push({
-					name: attachment.name!,
-					value: fromHtml(attachment.value!)
+					name: attachment.name,
+					value: fromHtml(attachment.value)
 				});
 			}
 		}
diff --git a/src/remote/activitypub/models/tag.ts b/src/remote/activitypub/models/tag.ts
index 8d2008d1d..d25cb463f 100644
--- a/src/remote/activitypub/models/tag.ts
+++ b/src/remote/activitypub/models/tag.ts
@@ -1,26 +1,18 @@
-import { IIcon } from './icon';
-import { IIdentifier } from './identifier';
+import { toArray } from '../../../prelude/array';
+import { IObject, isHashtag, IApHashtag } from '../type';
 
-/***
- * tag (ActivityPub)
- */
-export type ITag = {
-	id: string;
-	type: string;
-	name?: string;
-	value?: string;
-	updated?: Date;
-	icon?: IIcon;
-	identifier?: IIdentifier;
-};
-
-export function extractHashtags(tags: ITag[] | null | undefined): string[] {
+export function extractApHashtags(tags: IObject | IObject[] | null | undefined) {
 	if (tags == null) return [];
 
-	const hashtags = tags.filter(tag => tag.type === 'Hashtag' && typeof tag.name == 'string');
+	const hashtags = extractApHashtagObjects(tags);
 
 	return hashtags.map(tag => {
-		const m = tag.name ? tag.name.match(/^#(.+)/) : null;
+		const m = tag.name.match(/^#(.+)/);
 		return m ? m[1] : null;
-	}).filter(x => x != null) as string[];
+	}).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);
 }
diff --git a/src/remote/activitypub/renderer/mention.ts b/src/remote/activitypub/renderer/mention.ts
index 889be5d85..3b5e8f27a 100644
--- a/src/remote/activitypub/renderer/mention.ts
+++ b/src/remote/activitypub/renderer/mention.ts
@@ -4,6 +4,6 @@ import { Users } from '../../../models';
 
 export default (mention: User) => ({
 	type: 'Mention',
-	href: Users.isRemoteUser(mention) ? mention.uri : `${config.url}/@${(mention as ILocalUser).username}`,
+	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}`,
 });
diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts
index 35a070516..5cae8ee72 100644
--- a/src/remote/activitypub/type.ts
+++ b/src/remote/activitypub/type.ts
@@ -20,7 +20,8 @@ export interface IObject {
 	icon?: any;
 	image?: any;
 	url?: string;
-	tag?: any[];
+	href?: string;
+	tag?: IObject | IObject[];
 	sensitive?: boolean;
 }
 
@@ -130,6 +131,45 @@ export const isOrderedCollection = (object: IObject): object is IOrderedCollecti
 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 &&
+	object.type === '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=>
+	object.type === 'Mention' &&
+	typeof object.href === 'string';
+
+export interface IApHashtag extends IObject {
+	type: 'Hashtag';
+	name: string;
+}
+
+export const isHashtag = (object: IObject): object is IApHashtag =>
+	object.type === 'Hashtag' &&
+	typeof object.name === 'string';
+
+export interface IApEmoji extends IObject {
+	type: 'Emoji';
+	updated: Date;
+}
+
+export const isEmoji = (object: IObject): object is IApEmoji =>
+	object.type === 'Emoji' && !Array.isArray(object.icon) && object.icon.url != null;
+
 export interface ICreate extends IActivity {
 	type: 'Create';
 }