mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-10-31 13:34:12 +00:00 
			
		
		
		
	filter url properties by mediaType
				
					
				
			This commit is contained in:
		
							parent
							
								
									d8d94b65a0
								
							
						
					
					
						commit
						910b83c531
					
				
					 4 changed files with 235 additions and 34 deletions
				
			
		|  | @ -45,25 +45,36 @@ export class ApUtilityService { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	/** | 	/** | ||||||
| 	 * Searches a list of URLs or Links for the first one matching a given target URL's host authority. | 	 * Finds the "best" URL for a given AP object. | ||||||
| 	 * Returns null if none match. | 	 * The list of URLs is first filtered via findSameAuthorityUrl, then further filtered based on mediaType, and finally sorted to select the best one. | ||||||
| 	 * @param targetUrl URL with the target host authority | 	 * @throws {IdentifiableError} if object does not have an ID | ||||||
| 	 * @param searchUrls URL, Link, or array to search for matching URLs | 	 * @returns the best URL, or null if none were found | ||||||
| 	 */ | 	 */ | ||||||
| 	public findSameAuthorityUrl(targetUrl: string, searchUrls: string | IObject | undefined | (string | IObject)[]): string | null { | 	public findBestObjectUrl(object: IObject): string | null { | ||||||
|  | 		const targetUrl = getApId(object); | ||||||
| 		const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl); | 		const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl); | ||||||
| 
 | 
 | ||||||
| 		const match = toArray(searchUrls) | 		const rawUrls = toArray(object.url); | ||||||
| 			.map(raw => getOneApHrefNullable(raw)) | 		const acceptableUrls = rawUrls | ||||||
| 			.find(url => { | 			.map(raw => ({ | ||||||
|  | 				url: getOneApHrefNullable(raw), | ||||||
|  | 				type: typeof(raw) === 'object' | ||||||
|  | 					? raw.mediaType?.toLowerCase() | ||||||
|  | 					: undefined, | ||||||
|  | 			})) | ||||||
|  | 			.filter(({ url, type }) => { | ||||||
| 				if (!url) return false; | 				if (!url) return false; | ||||||
| 				if (!this.checkHttps(url)) return false; | 				if (!this.checkHttps(url)) return false; | ||||||
|  | 				if (!isAcceptableUrlType(type)) return false; | ||||||
| 
 | 
 | ||||||
| 				const urlAuthority = this.utilityService.punyHostPSLDomain(url); | 				const urlAuthority = this.utilityService.punyHostPSLDomain(url); | ||||||
| 				return urlAuthority === targetAuthority; | 				return urlAuthority === targetAuthority; | ||||||
|  | 			}) | ||||||
|  | 			.sort((a, b) => { | ||||||
|  | 				return rankUrlType(a.type) - rankUrlType(b.type); | ||||||
| 			}); | 			}); | ||||||
| 
 | 
 | ||||||
| 		return match ?? null; | 		return acceptableUrls[0]?.url ?? null; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	/** | 	/** | ||||||
|  | @ -78,3 +89,20 @@ export class ApUtilityService { | ||||||
| 		return url.startsWith('https://') || (url.startsWith('http://') && isNonProd); | 		return url.startsWith('https://') || (url.startsWith('http://') && isNonProd); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | function isAcceptableUrlType(type: string | undefined): boolean { | ||||||
|  | 	if (!type) return true; | ||||||
|  | 	if (type.startsWith('text/')) return true; | ||||||
|  | 	if (type.startsWith('application/ld+json')) return true; | ||||||
|  | 	if (type.startsWith('application/activity+json')) return true; | ||||||
|  | 	return false; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function rankUrlType(type: string | undefined): number { | ||||||
|  | 	if (!type) return 2; | ||||||
|  | 	if (type === 'text/html') return 0; | ||||||
|  | 	if (type.startsWith('text/')) return 1; | ||||||
|  | 	if (type.startsWith('application/ld+json')) return 3; | ||||||
|  | 	if (type.startsWith('application/activity+json')) return 4; | ||||||
|  | 	return 5; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -180,7 +180,7 @@ export class ApNoteService { | ||||||
| 			throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`); | 			throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		const url = this.apUtilityService.findSameAuthorityUrl(note.id, note.url); | 		const url = this.apUtilityService.findBestObjectUrl(note); | ||||||
| 
 | 
 | ||||||
| 		this.logger.info(`Creating the Note: ${note.id}`); | 		this.logger.info(`Creating the Note: ${note.id}`); | ||||||
| 
 | 
 | ||||||
|  | @ -395,7 +395,7 @@ export class ApNoteService { | ||||||
| 			throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`); | 			throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		const url = this.apUtilityService.findSameAuthorityUrl(note.id, note.url); | 		const url = this.apUtilityService.findBestObjectUrl(note); | ||||||
| 
 | 
 | ||||||
| 		this.logger.info(`Creating the Note: ${note.id}`); | 		this.logger.info(`Creating the Note: ${note.id}`); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -351,8 +351,7 @@ export class ApPersonService implements OnModuleInit { | ||||||
| 			throw new UnrecoverableError(`Refusing to create person without id: ${uri}`); | 			throw new UnrecoverableError(`Refusing to create person without id: ${uri}`); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		const personId = getApId(person); | 		const url = this.apUtilityService.findBestObjectUrl(person); | ||||||
| 		const url = this.apUtilityService.findSameAuthorityUrl(personId, person.url); |  | ||||||
| 
 | 
 | ||||||
| 		// Create user
 | 		// Create user
 | ||||||
| 		let user: MiRemoteUser | null = null; | 		let user: MiRemoteUser | null = null; | ||||||
|  | @ -561,8 +560,8 @@ export class ApPersonService implements OnModuleInit { | ||||||
| 		if (person.id == null) { | 		if (person.id == null) { | ||||||
| 			throw new UnrecoverableError(`Refusing to update person without id: ${uri}`); | 			throw new UnrecoverableError(`Refusing to update person without id: ${uri}`); | ||||||
| 		} | 		} | ||||||
| 		const personId = getApId(person); | 
 | ||||||
| 		const url = this.apUtilityService.findSameAuthorityUrl(personId, person.url); | 		const url = this.apUtilityService.findBestObjectUrl(person); | ||||||
| 
 | 
 | ||||||
| 		const updates = { | 		const updates = { | ||||||
| 			lastFetchedAt: new Date(), | 			lastFetchedAt: new Date(), | ||||||
|  |  | ||||||
|  | @ -95,83 +95,257 @@ describe(ApUtilityService, () => { | ||||||
| 		}); | 		}); | ||||||
| 	}); | 	}); | ||||||
| 
 | 
 | ||||||
| 	describe('findSameAuthorityUrl', () => { | 	describe('findBestObjectUrl', () => { | ||||||
| 		it('should return null when input is undefined', () => { | 		it('should return null when input is undefined', () => { | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com', undefined); | 			const object = { | ||||||
|  | 				id: 'https://example.com', | ||||||
|  | 				url: undefined, | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBeNull(); | 			expect(result).toBeNull(); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should return null when input is empty array', () => { | 		it('should return null when input is empty array', () => { | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com', []); | 			const object = { | ||||||
|  | 				id: 'https://example.com', | ||||||
|  | 				url: [] as string[], | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBeNull(); | 			expect(result).toBeNull(); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should return return url if string input matches', () => { | 		it('should return return url if string input matches', () => { | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', 'https://example.com/2'); | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: 'https://example.com/2', | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBe('https://example.com/2'); | 			expect(result).toBe('https://example.com/2'); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should return return url if object input matches', () => { | 		it('should return return url if object input matches', () => { | ||||||
| 			const input = { | 			const object = { | ||||||
| 				href: 'https://example.com/2', | 				id: 'https://example.com/1', | ||||||
|  | 				url: { | ||||||
|  | 					href: 'https://example.com/2', | ||||||
|  | 				} as IObject, | ||||||
| 			} as IObject; | 			} as IObject; | ||||||
| 
 | 
 | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', input); | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBe('https://example.com/2'); | 			expect(result).toBe('https://example.com/2'); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should return return url if string[] input matches', () => { | 		it('should return return url if string[] input matches', () => { | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['https://example.com/2']); | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: ['https://example.com/2'], | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBe('https://example.com/2'); | 			expect(result).toBe('https://example.com/2'); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should return return url if object[] input matches', () => { | 		it('should return return url if object[] input matches', () => { | ||||||
| 			const input = { | 			const object = { | ||||||
| 				href: 'https://example.com/2', | 				id: 'https://example.com/1', | ||||||
|  | 				url: [{ | ||||||
|  | 					href: 'https://example.com/2', | ||||||
|  | 				} as IObject], | ||||||
| 			} as IObject; | 			} as IObject; | ||||||
| 
 | 
 | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', [input]); | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBe('https://example.com/2'); | 			expect(result).toBe('https://example.com/2'); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should skip invalid entries', () => { | 		it('should skip invalid entries', () => { | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', [{} as IObject, 'https://example.com/2']); | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: [{} as IObject, 'https://example.com/2'], | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBe('https://example.com/2'); | 			expect(result).toBe('https://example.com/2'); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should return first match', () => { | 		it('should allow empty mediaType', () => { | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['https://example.com/2', 'https://example.com/3']); | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: { | ||||||
|  | 					href: 'https://example.com/2', | ||||||
|  | 				} as IObject, | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
|  | 
 | ||||||
|  | 			expect(result).toBe('https://example.com/2'); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		it('should allow text/html mediaType', () => { | ||||||
|  | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: { | ||||||
|  | 					href: 'https://example.com/2', | ||||||
|  | 					mediaType: 'text/html', | ||||||
|  | 				} as IObject, | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
|  | 
 | ||||||
|  | 			expect(result).toBe('https://example.com/2'); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		it('should allow other text/ mediaTypes', () => { | ||||||
|  | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: { | ||||||
|  | 					href: 'https://example.com/2', | ||||||
|  | 					mediaType: 'text/imaginary', | ||||||
|  | 				} as IObject, | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
|  | 
 | ||||||
|  | 			expect(result).toBe('https://example.com/2'); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		it('should allow application/ld+json mediaType', () => { | ||||||
|  | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: { | ||||||
|  | 					href: 'https://example.com/2', | ||||||
|  | 					mediaType: 'application/ld+json;profile=https://www.w3.org/ns/activitystreams', | ||||||
|  | 				} as IObject, | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
|  | 
 | ||||||
|  | 			expect(result).toBe('https://example.com/2'); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		it('should allow application/activity+json mediaType', () => { | ||||||
|  | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: { | ||||||
|  | 					href: 'https://example.com/2', | ||||||
|  | 					mediaType: 'application/activity+json', | ||||||
|  | 				} as IObject, | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
|  | 
 | ||||||
|  | 			expect(result).toBe('https://example.com/2'); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		it('should reject other mediaTypes', () => { | ||||||
|  | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: [ | ||||||
|  | 					{ | ||||||
|  | 						href: 'https://example.com/2', | ||||||
|  | 						mediaType: 'application/json', | ||||||
|  | 					} as IObject, | ||||||
|  | 					{ | ||||||
|  | 						href: 'https://example.com/3', | ||||||
|  | 						mediaType: 'image/jpeg', | ||||||
|  | 					} as IObject, | ||||||
|  | 				], | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
|  | 
 | ||||||
|  | 			expect(result).toBeNull(); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		it('should return best match', () => { | ||||||
|  | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: [ | ||||||
|  | 					'https://example.com/2', | ||||||
|  | 					{ | ||||||
|  | 						href: 'https://example.com/3', | ||||||
|  | 					} as IObject, | ||||||
|  | 					{ | ||||||
|  | 						href: 'https://example.com/4', | ||||||
|  | 						mediaType: 'text/html', | ||||||
|  | 					} as IObject, | ||||||
|  | 					{ | ||||||
|  | 						href: 'https://example.com/5', | ||||||
|  | 						mediaType: 'text/plain', | ||||||
|  | 					} as IObject, | ||||||
|  | 					{ | ||||||
|  | 						href: 'https://example.com/6', | ||||||
|  | 						mediaType: 'application/ld+json', | ||||||
|  | 					} as IObject, | ||||||
|  | 					{ | ||||||
|  | 						href: 'https://example.com/7', | ||||||
|  | 						mediaType: 'application/activity+json', | ||||||
|  | 					} as IObject, | ||||||
|  | 					{ | ||||||
|  | 						href: 'https://example.com/8', | ||||||
|  | 						mediaType: 'image/jpeg', | ||||||
|  | 					} as IObject, | ||||||
|  | 				], | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
|  | 
 | ||||||
|  | 			expect(result).toBe('https://example.com/4'); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		it('should return first match in case of ties', () => { | ||||||
|  | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: ['https://example.com/2', 'https://example.com/3'], | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBe('https://example.com/2'); | 			expect(result).toBe('https://example.com/2'); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should skip invalid scheme', () => { | 		it('should skip invalid scheme', () => { | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['file://example.com/1', 'https://example.com/2']); | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: ['file://example.com/1', 'https://example.com/2'], | ||||||
|  | 			} as IObject; | ||||||
|  | 
 | ||||||
|  | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBe('https://example.com/2'); | 			expect(result).toBe('https://example.com/2'); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should skip HTTP in production', () => { | 		it('should skip HTTP in production', () => { | ||||||
|  | 			// noinspection HttpUrlsUsage
 | ||||||
|  | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: ['http://example.com/1', 'https://example.com/2'], | ||||||
|  | 			} as IObject; | ||||||
| 			env.NODE_ENV = 'production'; | 			env.NODE_ENV = 'production'; | ||||||
| 
 | 
 | ||||||
| 			// noinspection HttpUrlsUsage
 | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['http://example.com/1', 'https://example.com/2']); |  | ||||||
| 
 | 
 | ||||||
| 			expect(result).toBe('https://example.com/2'); | 			expect(result).toBe('https://example.com/2'); | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
| 		it('should allow HTTP in non-prod', () => { | 		it('should allow HTTP in non-prod', () => { | ||||||
|  | 			// noinspection HttpUrlsUsage
 | ||||||
|  | 			const object = { | ||||||
|  | 				id: 'https://example.com/1', | ||||||
|  | 				url: ['http://example.com/1', 'https://example.com/2'], | ||||||
|  | 			} as IObject; | ||||||
| 			env.NODE_ENV = 'test'; | 			env.NODE_ENV = 'test'; | ||||||
| 
 | 
 | ||||||
| 			// noinspection HttpUrlsUsage
 | 			const result = serviceUnderTest.findBestObjectUrl(object); | ||||||
| 			const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['http://example.com/1', 'https://example.com/2']); |  | ||||||
| 
 | 
 | ||||||
| 			// noinspection HttpUrlsUsage
 | 			// noinspection HttpUrlsUsage
 | ||||||
| 			expect(result).toBe('http://example.com/1'); | 			expect(result).toBe('http://example.com/1'); | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue