AP Actorの修正 (#7573)
* AP Actorの修正 * Add ActivityPub test * Fix person * Test * ap test * Revert "Test" This reverts commit 3c493eff4e89f94fd33f25189ba3bc96ef4366b3. * Test comment * fix * fix * Update inbox * indent * nl * indent * TODO * Fix inbox * Update test
This commit is contained in:
		
							parent
							
								
									cb42f94d9c
								
							
						
					
					
						commit
						1772af9583
					
				
					 7 changed files with 169 additions and 70 deletions
				
			
		|  | @ -325,7 +325,6 @@ export class UserRepository extends Repository<User> { | ||||||
| 
 | 
 | ||||||
| 	//#region Validators
 | 	//#region Validators
 | ||||||
| 	public validateLocalUsername = $.str.match(/^\w{1,20}$/); | 	public validateLocalUsername = $.str.match(/^\w{1,20}$/); | ||||||
| 	public validateRemoteUsername = $.str.match(/^\w([\w-.]*\w)?$/); |  | ||||||
| 	public validatePassword = $.str.min(1); | 	public validatePassword = $.str.min(1); | ||||||
| 	public validateName = $.str.min(1).max(50); | 	public validateName = $.str.min(1).max(50); | ||||||
| 	public validateDescription = $.str.min(1).max(500); | 	public validateDescription = $.str.min(1).max(500); | ||||||
|  |  | ||||||
|  | @ -65,6 +65,11 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => { | ||||||
| 		return `skip: failed to resolve user`; | 		return `skip: failed to resolve user`; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// publicKey がなくても終了
 | ||||||
|  | 	if (authUser.key == null) { | ||||||
|  | 		return `skip: failed to resolve user publicKey`; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	// HTTP-Signatureの検証
 | 	// HTTP-Signatureの検証
 | ||||||
| 	const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); | 	const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); | ||||||
| 
 | 
 | ||||||
|  | @ -89,6 +94,10 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => { | ||||||
| 				return `skip: LD-Signatureのユーザーが取得できませんでした`; | 				return `skip: LD-Signatureのユーザーが取得できませんでした`; | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
|  | 			if (authUser.key == null) { | ||||||
|  | 				return `skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした`; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
| 			// LD-Signature検証
 | 			// LD-Signature検証
 | ||||||
| 			const ldSignature = new LdSignature(); | 			const ldSignature = new LdSignature(); | ||||||
| 			const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); | 			const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); | ||||||
|  |  | ||||||
|  | @ -98,7 +98,7 @@ export default class DbResolver { | ||||||
| 
 | 
 | ||||||
| 		if (user == null) return null; | 		if (user == null) return null; | ||||||
| 
 | 
 | ||||||
| 		const key = await UserPublickeys.findOneOrFail(user.id); | 		const key = await UserPublickeys.findOne(user.id); | ||||||
| 
 | 
 | ||||||
| 		return { | 		return { | ||||||
| 			user, | 			user, | ||||||
|  | @ -127,7 +127,7 @@ export default class DbResolver { | ||||||
| 
 | 
 | ||||||
| export type AuthUser = { | export type AuthUser = { | ||||||
| 	user: IRemoteUser; | 	user: IRemoteUser; | ||||||
| 	key: UserPublickey; | 	key?: UserPublickey; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| type UriParseResult = { | type UriParseResult = { | ||||||
|  |  | ||||||
|  | @ -1,10 +1,11 @@ | ||||||
| import { URL } from 'url'; | import { URL } from 'url'; | ||||||
| import * as promiseLimit from 'promise-limit'; | import * as promiseLimit from 'promise-limit'; | ||||||
| 
 | 
 | ||||||
|  | import $, { Context } from 'cafy'; | ||||||
| import config from '@/config'; | import config from '@/config'; | ||||||
| import Resolver from '../resolver'; | import Resolver from '../resolver'; | ||||||
| import { resolveImage } from './image'; | import { resolveImage } from './image'; | ||||||
| import { isCollectionOrOrderedCollection, isCollection, IPerson, getApId, getOneApHrefNullable, IObject, isPropertyValue, IApPropertyValue, getApType } from '../type'; | import { isCollectionOrOrderedCollection, isCollection, IActor, getApId, getOneApHrefNullable, IObject, isPropertyValue, IApPropertyValue, getApType, isActor } from '../type'; | ||||||
| import { fromHtml } from '../../../mfm/from-html'; | import { fromHtml } from '../../../mfm/from-html'; | ||||||
| import { htmlToMfm } from '../misc/html-to-mfm'; | import { htmlToMfm } from '../misc/html-to-mfm'; | ||||||
| import { resolveNote, extractEmojis } from './note'; | import { resolveNote, extractEmojis } from './note'; | ||||||
|  | @ -23,7 +24,6 @@ import { UserPublickey } from '../../../models/entities/user-publickey'; | ||||||
| import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error'; | import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error'; | ||||||
| import { toPuny } from '@/misc/convert-host'; | import { toPuny } from '@/misc/convert-host'; | ||||||
| import { UserProfile } from '../../../models/entities/user-profile'; | import { UserProfile } from '../../../models/entities/user-profile'; | ||||||
| import { validActor } from '../../../remote/activitypub/type'; |  | ||||||
| import { getConnection } from 'typeorm'; | import { getConnection } from 'typeorm'; | ||||||
| import { toArray } from '../../../prelude/array'; | import { toArray } from '../../../prelude/array'; | ||||||
| import { fetchInstanceMetadata } from '../../../services/fetch-instance-metadata'; | import { fetchInstanceMetadata } from '../../../services/fetch-instance-metadata'; | ||||||
|  | @ -32,58 +32,49 @@ import { normalizeForSearch } from '@/misc/normalize-for-search'; | ||||||
| const logger = apLogger; | const logger = apLogger; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Validate Person object |  * Validate and convert to actor object | ||||||
|  * @param x Fetched person object |  * @param x Fetched object | ||||||
|  * @param uri Fetch target URI |  * @param uri Fetch target URI | ||||||
|  */ |  */ | ||||||
| function validatePerson(x: any, uri: string) { | function validateActor(x: IObject, uri: string): IActor { | ||||||
| 	const expectHost = toPuny(new URL(uri).hostname); | 	const expectHost = toPuny(new URL(uri).hostname); | ||||||
| 
 | 
 | ||||||
| 	if (x == null) { | 	if (x == null) { | ||||||
| 		return new Error('invalid person: object is null'); | 		throw new Error('invalid Actor: object is null'); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if (!validActor.includes(x.type)) { | 	if (!isActor(x)) { | ||||||
| 		return new Error(`invalid person: object is not a person or service '${x.type}'`); | 		throw new Error(`invalid Actor type '${x.type}'`); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if (typeof x.preferredUsername !== 'string') { | 	const validate = (name: string, value: any, validater: Context) => { | ||||||
| 		return new Error('invalid person: preferredUsername is not a string'); | 		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)?$/)); | ||||||
|  | 	validate('name', x.name, $.optional.nullable.str.max(128)); | ||||||
|  | 	validate('summary', x.summary, $.optional.nullable.str.max(2048)); | ||||||
|  | 
 | ||||||
|  | 	const idHost = toPuny(new URL(x.id!).hostname); | ||||||
|  | 	if (idHost !== expectHost) { | ||||||
|  | 		throw new Error('invalid Actor: id has different host'); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if (typeof x.inbox !== 'string') { | 	if (x.publicKey) { | ||||||
| 		return new Error('invalid person: inbox is not a string'); | 		if (typeof x.publicKey.id !== 'string') { | ||||||
| 	} | 			throw new Error('invalid Actor: publicKey.id is not a string'); | ||||||
|  | 		} | ||||||
| 
 | 
 | ||||||
| 	if (!Users.validateRemoteUsername.ok(x.preferredUsername)) { | 		const publicKeyIdHost = toPuny(new URL(x.publicKey.id).hostname); | ||||||
| 		return new Error('invalid person: invalid username'); | 		if (publicKeyIdHost !== expectHost) { | ||||||
| 	} | 			throw new Error('invalid Actor: publicKey.id has different host'); | ||||||
| 
 |  | ||||||
| 	if (x.name != null && x.name != '') { |  | ||||||
| 		if (!Users.validateName.ok(x.name)) { |  | ||||||
| 			return new Error('invalid person: invalid name'); |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if (typeof x.id !== 'string') { | 	return x; | ||||||
| 		return new Error('invalid person: id is not a string'); |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	const idHost = toPuny(new URL(x.id).hostname); |  | ||||||
| 	if (idHost !== expectHost) { |  | ||||||
| 		return new Error('invalid person: id has different host'); |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if (typeof x.publicKey.id !== 'string') { |  | ||||||
| 		return new Error('invalid person: publicKey.id is not a string'); |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	const publicKeyIdHost = toPuny(new URL(x.publicKey.id).hostname); |  | ||||||
| 	if (publicKeyIdHost !== expectHost) { |  | ||||||
| 		return new Error('invalid person: publicKey.id has different host'); |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return null; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  | @ -121,13 +112,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us | ||||||
| 
 | 
 | ||||||
| 	const object = await resolver.resolve(uri) as any; | 	const object = await resolver.resolve(uri) as any; | ||||||
| 
 | 
 | ||||||
| 	const err = validatePerson(object, uri); | 	const person = validateActor(object, uri); | ||||||
| 
 |  | ||||||
| 	if (err) { |  | ||||||
| 		throw err; |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	const person: IPerson = object; |  | ||||||
| 
 | 
 | ||||||
| 	logger.info(`Creating the Person: ${person.id}`); | 	logger.info(`Creating the Person: ${person.id}`); | ||||||
| 
 | 
 | ||||||
|  | @ -178,11 +163,13 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us | ||||||
| 				userHost: host | 				userHost: host | ||||||
| 			})); | 			})); | ||||||
| 
 | 
 | ||||||
| 			await transactionalEntityManager.save(new UserPublickey({ | 			if (person.publicKey) { | ||||||
| 				userId: user.id, | 				await transactionalEntityManager.save(new UserPublickey({ | ||||||
| 				keyId: person.publicKey.id, | 					userId: user.id, | ||||||
| 				keyPem: person.publicKey.publicKeyPem | 					keyId: person.publicKey.id, | ||||||
| 			})); | 					keyPem: person.publicKey.publicKeyPem | ||||||
|  | 				})); | ||||||
|  | 			} | ||||||
| 		}); | 		}); | ||||||
| 	} catch (e) { | 	} catch (e) { | ||||||
| 		// duplicate key error
 | 		// duplicate key error
 | ||||||
|  | @ -294,13 +281,7 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint | ||||||
| 
 | 
 | ||||||
| 	const object = hint || await resolver.resolve(uri) as any; | 	const object = hint || await resolver.resolve(uri) as any; | ||||||
| 
 | 
 | ||||||
| 	const err = validatePerson(object, uri); | 	const person = validateActor(object, uri); | ||||||
| 
 |  | ||||||
| 	if (err) { |  | ||||||
| 		throw err; |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	const person: IPerson = object; |  | ||||||
| 
 | 
 | ||||||
| 	logger.info(`Updating the Person: ${person.id}`); | 	logger.info(`Updating the Person: ${person.id}`); | ||||||
| 
 | 
 | ||||||
|  | @ -358,10 +339,12 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint | ||||||
| 	// Update user
 | 	// Update user
 | ||||||
| 	await Users.update(exist.id, updates); | 	await Users.update(exist.id, updates); | ||||||
| 
 | 
 | ||||||
| 	await UserPublickeys.update({ userId: exist.id }, { | 	if (person.publicKey) { | ||||||
| 		keyId: person.publicKey.id, | 		await UserPublickeys.update({ userId: exist.id }, { | ||||||
| 		keyPem: person.publicKey.publicKeyPem | 			keyId: person.publicKey.id, | ||||||
| 	}); | 			keyPem: person.publicKey.publicKeyPem | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	await UserProfiles.update({ userId: exist.id }, { | 	await UserProfiles.update({ userId: exist.id }, { | ||||||
| 		url: getOneApHrefNullable(person.url), | 		url: getOneApHrefNullable(person.url), | ||||||
|  |  | ||||||
|  | @ -142,25 +142,25 @@ export const isTombstone = (object: IObject): object is ITombstone => | ||||||
| 
 | 
 | ||||||
| export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application']; | export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application']; | ||||||
| 
 | 
 | ||||||
| export const isActor = (object: IObject): object is IPerson => | export const isActor = (object: IObject): object is IActor => | ||||||
| 	validActor.includes(getApType(object)); | 	validActor.includes(getApType(object)); | ||||||
| 
 | 
 | ||||||
| export interface IPerson extends IObject { | export interface IActor extends IObject { | ||||||
| 	type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application'; | 	type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application'; | ||||||
| 	name?: string; | 	name?: string; | ||||||
| 	preferredUsername?: string; | 	preferredUsername?: string; | ||||||
| 	manuallyApprovesFollowers?: boolean; | 	manuallyApprovesFollowers?: boolean; | ||||||
| 	discoverable?: boolean; | 	discoverable?: boolean; | ||||||
| 	inbox?: string; | 	inbox: string; | ||||||
| 	sharedInbox?: string;	// 後方互換性のため
 | 	sharedInbox?: string;	// 後方互換性のため
 | ||||||
| 	publicKey: { | 	publicKey?: { | ||||||
| 		id: string; | 		id: string; | ||||||
| 		publicKeyPem: string; | 		publicKeyPem: string; | ||||||
| 	}; | 	}; | ||||||
| 	followers?: string | ICollection | IOrderedCollection; | 	followers?: string | ICollection | IOrderedCollection; | ||||||
| 	following?: string | ICollection | IOrderedCollection; | 	following?: string | ICollection | IOrderedCollection; | ||||||
| 	featured?: string | IOrderedCollection; | 	featured?: string | IOrderedCollection; | ||||||
| 	outbox?: string | IOrderedCollection; | 	outbox: string | IOrderedCollection; | ||||||
| 	endpoints?: { | 	endpoints?: { | ||||||
| 		sharedInbox?: string; | 		sharedInbox?: string; | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
							
								
								
									
										73
									
								
								test/activitypub.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								test/activitypub.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | ||||||
|  | /* | ||||||
|  |  * Tests for ActivityPub | ||||||
|  |  * | ||||||
|  |  * How to run the tests: | ||||||
|  |  * > npx cross-env TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT="./test/tsconfig.json" mocha test/activitypub.ts --require ts-node/register | ||||||
|  |  * | ||||||
|  |  * To specify test: | ||||||
|  |  * > npx cross-env TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT="./test/tsconfig.json" npx mocha test/activitypub.ts --require ts-node/register -g 'test name' | ||||||
|  |  */ | ||||||
|  | process.env.NODE_ENV = 'test'; | ||||||
|  | 
 | ||||||
|  | import rndstr from 'rndstr'; | ||||||
|  | import * as assert from 'assert'; | ||||||
|  | import { initTestDb } from './utils'; | ||||||
|  | 
 | ||||||
|  | describe('ActivityPub', () => { | ||||||
|  | 	before(async () => { | ||||||
|  | 		await initTestDb(); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	describe('Parse minimum object', () => { | ||||||
|  | 		const host = 'https://host1.test'; | ||||||
|  | 		const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; | ||||||
|  | 		const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; | ||||||
|  | 
 | ||||||
|  | 		const actor = { | ||||||
|  | 			'@context': 'https://www.w3.org/ns/activitystreams', | ||||||
|  | 			id: actorId, | ||||||
|  | 			type: 'Person', | ||||||
|  | 			preferredUsername, | ||||||
|  | 			inbox: `${actorId}/inbox`, | ||||||
|  | 			outbox: `${actorId}/outbox`, | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 		const post = { | ||||||
|  | 			'@context': 'https://www.w3.org/ns/activitystreams', | ||||||
|  | 			id: `${host}/users/${rndstr('0-9a-z', 8)}`, | ||||||
|  | 			type: 'Note', | ||||||
|  | 			attributedTo: actor.id, | ||||||
|  | 			to: 'https://www.w3.org/ns/activitystreams#Public', | ||||||
|  | 			content: 'あ', | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 		it('Minimum Actor', async () => { | ||||||
|  | 			const { MockResolver } = await import('./misc/mock-resolver'); | ||||||
|  | 			const { createPerson } = await import('../src/remote/activitypub/models/person'); | ||||||
|  | 
 | ||||||
|  | 			const resolver = new MockResolver(); | ||||||
|  | 			resolver._register(actor.id, actor); | ||||||
|  | 
 | ||||||
|  | 			const user = await createPerson(actor.id, resolver); | ||||||
|  | 
 | ||||||
|  | 			assert.deepStrictEqual(user.uri, actor.id); | ||||||
|  | 			assert.deepStrictEqual(user.username, actor.preferredUsername); | ||||||
|  | 			assert.deepStrictEqual(user.inbox, actor.inbox); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		it('Minimum Note', async () => { | ||||||
|  | 			const { MockResolver } = await import('./misc/mock-resolver'); | ||||||
|  | 			const { createNote } = await import('../src/remote/activitypub/models/note'); | ||||||
|  | 
 | ||||||
|  | 			const resolver = new MockResolver(); | ||||||
|  | 			resolver._register(actor.id, actor); | ||||||
|  | 			resolver._register(post.id, post); | ||||||
|  | 
 | ||||||
|  | 			const note = await createNote(post.id, resolver, true); | ||||||
|  | 
 | ||||||
|  | 			assert.deepStrictEqual(note?.uri, post.id); | ||||||
|  | 			assert.deepStrictEqual(note?.visibility, 'public'); | ||||||
|  | 			assert.deepStrictEqual(note?.text, post.content); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  | }); | ||||||
							
								
								
									
										35
									
								
								test/misc/mock-resolver.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								test/misc/mock-resolver.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | ||||||
|  | import Resolver from '../../src/remote/activitypub/resolver'; | ||||||
|  | import { IObject } from '../../src/remote/activitypub/type'; | ||||||
|  | 
 | ||||||
|  | type MockResponse = { | ||||||
|  | 	type: string; | ||||||
|  | 	content: string; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export class MockResolver extends Resolver { | ||||||
|  | 	private _rs = new Map<string, MockResponse>(); | ||||||
|  | 	public async _register(uri: string, content: string | Record<string, any>, type = 'application/activity+json') { | ||||||
|  | 		this._rs.set(uri, { | ||||||
|  | 			type, | ||||||
|  | 			content: typeof content === 'string' ? content : JSON.stringify(content) | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public async resolve(value: string | IObject): Promise<IObject> { | ||||||
|  | 		if (typeof value !== 'string') return value; | ||||||
|  | 
 | ||||||
|  | 		const r = this._rs.get(value); | ||||||
|  | 
 | ||||||
|  | 		if (!r) { | ||||||
|  | 			throw { | ||||||
|  | 				name: `StatusError`, | ||||||
|  | 				statusCode: 404, | ||||||
|  | 				message: `Not registed for mock` | ||||||
|  | 			}; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		const object = JSON.parse(r.content); | ||||||
|  | 
 | ||||||
|  | 		return object; | ||||||
|  | 	} | ||||||
|  | } | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue