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
 | ||||
| 	public validateLocalUsername = $.str.match(/^\w{1,20}$/); | ||||
| 	public validateRemoteUsername = $.str.match(/^\w([\w-.]*\w)?$/); | ||||
| 	public validatePassword = $.str.min(1); | ||||
| 	public validateName = $.str.min(1).max(50); | ||||
| 	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`; | ||||
| 	} | ||||
| 
 | ||||
| 	// publicKey がなくても終了
 | ||||
| 	if (authUser.key == null) { | ||||
| 		return `skip: failed to resolve user publicKey`; | ||||
| 	} | ||||
| 
 | ||||
| 	// HTTP-Signatureの検証
 | ||||
| 	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のユーザーが取得できませんでした`; | ||||
| 			} | ||||
| 
 | ||||
| 			if (authUser.key == null) { | ||||
| 				return `skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした`; | ||||
| 			} | ||||
| 
 | ||||
| 			// LD-Signature検証
 | ||||
| 			const ldSignature = new LdSignature(); | ||||
| 			const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); | ||||
|  |  | |||
|  | @ -98,7 +98,7 @@ export default class DbResolver { | |||
| 
 | ||||
| 		if (user == null) return null; | ||||
| 
 | ||||
| 		const key = await UserPublickeys.findOneOrFail(user.id); | ||||
| 		const key = await UserPublickeys.findOne(user.id); | ||||
| 
 | ||||
| 		return { | ||||
| 			user, | ||||
|  | @ -127,7 +127,7 @@ export default class DbResolver { | |||
| 
 | ||||
| export type AuthUser = { | ||||
| 	user: IRemoteUser; | ||||
| 	key: UserPublickey; | ||||
| 	key?: UserPublickey; | ||||
| }; | ||||
| 
 | ||||
| type UriParseResult = { | ||||
|  |  | |||
|  | @ -1,10 +1,11 @@ | |||
| import { URL } from 'url'; | ||||
| import * as promiseLimit from 'promise-limit'; | ||||
| 
 | ||||
| import $, { Context } from 'cafy'; | ||||
| import config from '@/config'; | ||||
| import Resolver from '../resolver'; | ||||
| 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 { htmlToMfm } from '../misc/html-to-mfm'; | ||||
| 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 { toPuny } from '@/misc/convert-host'; | ||||
| import { UserProfile } from '../../../models/entities/user-profile'; | ||||
| import { validActor } from '../../../remote/activitypub/type'; | ||||
| import { getConnection } from 'typeorm'; | ||||
| import { toArray } from '../../../prelude/array'; | ||||
| import { fetchInstanceMetadata } from '../../../services/fetch-instance-metadata'; | ||||
|  | @ -32,58 +32,49 @@ import { normalizeForSearch } from '@/misc/normalize-for-search'; | |||
| const logger = apLogger; | ||||
| 
 | ||||
| /** | ||||
|  * Validate Person object | ||||
|  * @param x Fetched person object | ||||
|  * Validate and convert to actor object | ||||
|  * @param x Fetched object | ||||
|  * @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); | ||||
| 
 | ||||
| 	if (x == null) { | ||||
| 		return new Error('invalid person: object is null'); | ||||
| 		throw new Error('invalid Actor: object is null'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (!validActor.includes(x.type)) { | ||||
| 		return new Error(`invalid person: object is not a person or service '${x.type}'`); | ||||
| 	if (!isActor(x)) { | ||||
| 		throw new Error(`invalid Actor type '${x.type}'`); | ||||
| 	} | ||||
| 
 | ||||
| 	if (typeof x.preferredUsername !== 'string') { | ||||
| 		return new Error('invalid person: preferredUsername is not a string'); | ||||
| 	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)?$/)); | ||||
| 	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') { | ||||
| 		return new Error('invalid person: inbox is not a string'); | ||||
| 	} | ||||
| 	if (x.publicKey) { | ||||
| 		if (typeof x.publicKey.id !== 'string') { | ||||
| 			throw new Error('invalid Actor: publicKey.id is not a string'); | ||||
| 		} | ||||
| 
 | ||||
| 	if (!Users.validateRemoteUsername.ok(x.preferredUsername)) { | ||||
| 		return new Error('invalid person: invalid username'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (x.name != null && x.name != '') { | ||||
| 		if (!Users.validateName.ok(x.name)) { | ||||
| 			return new Error('invalid person: invalid name'); | ||||
| 		const publicKeyIdHost = toPuny(new URL(x.publicKey.id).hostname); | ||||
| 		if (publicKeyIdHost !== expectHost) { | ||||
| 			throw new Error('invalid Actor: publicKey.id has different host'); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if (typeof x.id !== 'string') { | ||||
| 		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; | ||||
| 	return x; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | @ -121,13 +112,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us | |||
| 
 | ||||
| 	const object = await resolver.resolve(uri) as any; | ||||
| 
 | ||||
| 	const err = validatePerson(object, uri); | ||||
| 
 | ||||
| 	if (err) { | ||||
| 		throw err; | ||||
| 	} | ||||
| 
 | ||||
| 	const person: IPerson = object; | ||||
| 	const person = validateActor(object, uri); | ||||
| 
 | ||||
| 	logger.info(`Creating the Person: ${person.id}`); | ||||
| 
 | ||||
|  | @ -178,11 +163,13 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us | |||
| 				userHost: host | ||||
| 			})); | ||||
| 
 | ||||
| 			await transactionalEntityManager.save(new UserPublickey({ | ||||
| 				userId: user.id, | ||||
| 				keyId: person.publicKey.id, | ||||
| 				keyPem: person.publicKey.publicKeyPem | ||||
| 			})); | ||||
| 			if (person.publicKey) { | ||||
| 				await transactionalEntityManager.save(new UserPublickey({ | ||||
| 					userId: user.id, | ||||
| 					keyId: person.publicKey.id, | ||||
| 					keyPem: person.publicKey.publicKeyPem | ||||
| 				})); | ||||
| 			} | ||||
| 		}); | ||||
| 	} catch (e) { | ||||
| 		// 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 err = validatePerson(object, uri); | ||||
| 
 | ||||
| 	if (err) { | ||||
| 		throw err; | ||||
| 	} | ||||
| 
 | ||||
| 	const person: IPerson = object; | ||||
| 	const person = validateActor(object, uri); | ||||
| 
 | ||||
| 	logger.info(`Updating the Person: ${person.id}`); | ||||
| 
 | ||||
|  | @ -358,10 +339,12 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint | |||
| 	// Update user
 | ||||
| 	await Users.update(exist.id, updates); | ||||
| 
 | ||||
| 	await UserPublickeys.update({ userId: exist.id }, { | ||||
| 		keyId: person.publicKey.id, | ||||
| 		keyPem: person.publicKey.publicKeyPem | ||||
| 	}); | ||||
| 	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), | ||||
|  |  | |||
|  | @ -142,25 +142,25 @@ export const isTombstone = (object: IObject): object is ITombstone => | |||
| 
 | ||||
| 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)); | ||||
| 
 | ||||
| export interface IPerson extends IObject { | ||||
| export interface IActor extends IObject { | ||||
| 	type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application'; | ||||
| 	name?: string; | ||||
| 	preferredUsername?: string; | ||||
| 	manuallyApprovesFollowers?: boolean; | ||||
| 	discoverable?: boolean; | ||||
| 	inbox?: string; | ||||
| 	inbox: string; | ||||
| 	sharedInbox?: string;	// 後方互換性のため
 | ||||
| 	publicKey: { | ||||
| 	publicKey?: { | ||||
| 		id: string; | ||||
| 		publicKeyPem: string; | ||||
| 	}; | ||||
| 	followers?: string | ICollection | IOrderedCollection; | ||||
| 	following?: string | ICollection | IOrderedCollection; | ||||
| 	featured?: string | IOrderedCollection; | ||||
| 	outbox?: string | IOrderedCollection; | ||||
| 	outbox: string | IOrderedCollection; | ||||
| 	endpoints?: { | ||||
| 		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