From fe849d7d69d6dda1cdc05fb19a38710134f76c66 Mon Sep 17 00:00:00 2001 From: Sonny Date: Tue, 12 Nov 2024 16:39:41 +0100 Subject: [PATCH] refactor: favicon controller --- .../controllers/favicons_controller.ts | 154 ++---------------- app/favicons/services/cache_service.ts | 17 ++ app/favicons/services/favicons_service.ts | 124 ++++++++++++++ app/favicons/types/favicon_type.ts | 6 + 4 files changed, 160 insertions(+), 141 deletions(-) create mode 100644 app/favicons/services/cache_service.ts create mode 100644 app/favicons/services/favicons_service.ts create mode 100644 app/favicons/types/favicon_type.ts diff --git a/app/favicons/controllers/favicons_controller.ts b/app/favicons/controllers/favicons_controller.ts index 482377f..323f0c1 100644 --- a/app/favicons/controllers/favicons_controller.ts +++ b/app/favicons/controllers/favicons_controller.ts @@ -1,28 +1,16 @@ -import { cache } from '#core/lib/cache'; -import FaviconNotFoundException from '#favicons/exceptions/favicon_not_found_exception'; +import { CacheService } from '#favicons/services/cache_service'; +import { FaviconService } from '#favicons/services/favicons_service'; +import { Favicon } from '#favicons/types/favicon_type'; import type { HttpContext } from '@adonisjs/core/http'; -import logger from '@adonisjs/core/services/logger'; -import { parse } from 'node-html-parser'; - -interface Favicon { - buffer: Buffer; - url: string; - type: string; - size: number; -} export default class FaviconsController { - private userAgent = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0'; - private relList = [ - 'icon', - 'shortcut icon', - 'apple-touch-icon', - 'apple-touch-icon-precomposed', - 'apple-touch-startup-image', - 'mask-icon', - 'fluid-icon', - ]; + private faviconService: FaviconService; + private cacheService: CacheService; + + constructor(faviconService: FaviconService, cacheService: CacheService) { + this.faviconService = faviconService; + this.cacheService = cacheService; + } async index(ctx: HttpContext) { const url = ctx.request.qs()?.url; @@ -30,128 +18,12 @@ export default class FaviconsController { throw new Error('Missing URL'); } - const cacheNs = cache.namespace('favicon'); - const favicon = await cacheNs.getOrSet({ - key: url, - ttl: '1h', - factory: () => this.tryGetFavicon(url), - }); + const favicon = await this.cacheService.getOrSetFavicon(url, () => + this.faviconService.getFavicon(url) + ); return this.sendImage(ctx, favicon); } - private async tryGetFavicon(url: string): Promise { - const faviconUrl = this.buildFaviconUrl(url, '/favicon.ico'); - try { - return await this.fetchFavicon(faviconUrl); - } catch { - logger.debug(`Unable to retrieve favicon from ${faviconUrl}`); - } - - const documentText = await this.fetchDocumentText(url); - const faviconPath = this.extractFaviconPath(documentText); - - if (!faviconPath) { - throw new FaviconNotFoundException(`No favicon path found in ${url}`); - } - - if (faviconPath.startsWith('http')) { - try { - return await this.fetchFavicon(faviconPath); - } catch { - logger.debug(`Unable to retrieve favicon from ${faviconPath}`); - } - } - - return this.fetchFaviconFromPath(url, faviconPath); - } - - private async fetchFavicon(url: string): Promise { - const response = await this.fetchWithUserAgent(url); - if (!response.ok) { - throw new FaviconNotFoundException(`Request to favicon ${url} failed`); - } - - const blob = await response.blob(); - if (!this.isImage(blob.type) || blob.size === 0) { - throw new FaviconNotFoundException(`Invalid image at ${url}`); - } - - return { - buffer: Buffer.from(await blob.arrayBuffer()), - url: response.url, - type: blob.type, - size: blob.size, - }; - } - - private async fetchDocumentText(url: string): Promise { - const response = await this.fetchWithUserAgent(url); - if (!response.ok) { - throw new FaviconNotFoundException(`Request to ${url} failed`); - } - - return await response.text(); - } - - private extractFaviconPath(html: string): string | undefined { - const document = parse(html); - const link = document - .getElementsByTagName('link') - .find((element) => this.relList.includes(element.getAttribute('rel')!)); - return link?.getAttribute('href'); - } - - private async fetchFaviconFromPath( - baseUrl: string, - path: string - ): Promise { - if (this.isBase64Image(path)) { - const buffer = this.convertBase64ToBuffer(path); - return { - buffer, - type: 'image/x-icon', - size: buffer.length, - url: path, - }; - } - - const faviconUrl = this.buildFaviconUrl(baseUrl, path); - return this.fetchFavicon(faviconUrl); - } - - private buildFaviconUrl(base: string, path: string): string { - const { origin } = new URL(base); - if (path.startsWith('/')) { - return origin + path; - } - - const basePath = this.urlWithoutSearchParams(base); - const baseUrl = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; - return `${baseUrl}/${path}`; - } - - private urlWithoutSearchParams(url: string): string { - const { protocol, host, pathname } = new URL(url); - return `${protocol}//${host}${pathname}`; - } - - private isImage(type: string): boolean { - return type.startsWith('image/'); - } - - private isBase64Image(data: string): boolean { - return data.startsWith('data:image/'); - } - - private convertBase64ToBuffer(base64: string): Buffer { - return Buffer.from(base64.split(',')[1], 'base64'); - } - - private async fetchWithUserAgent(url: string): Promise { - const headers = new Headers({ 'User-Agent': this.userAgent }); - return fetch(url, { headers }); - } - private sendImage(ctx: HttpContext, { buffer, type, size }: Favicon) { ctx.response.header('Content-Type', type); ctx.response.header('Content-Length', size.toString()); diff --git a/app/favicons/services/cache_service.ts b/app/favicons/services/cache_service.ts new file mode 100644 index 0000000..ce8071f --- /dev/null +++ b/app/favicons/services/cache_service.ts @@ -0,0 +1,17 @@ +import { cache } from '#core/lib/cache'; +import { Favicon } from '#favicons/types/favicon_type'; + +export class CacheService { + private cacheNs = cache.namespace('favicon'); + + async getOrSetFavicon( + url: string, + factory: () => Promise + ): Promise { + return this.cacheNs.getOrSet({ + key: url, + ttl: '1h', + factory, + }); + } +} diff --git a/app/favicons/services/favicons_service.ts b/app/favicons/services/favicons_service.ts new file mode 100644 index 0000000..a8195c0 --- /dev/null +++ b/app/favicons/services/favicons_service.ts @@ -0,0 +1,124 @@ +import FaviconNotFoundException from '#favicons/exceptions/favicon_not_found_exception'; +import { Favicon } from '#favicons/types/favicon_type'; +import logger from '@adonisjs/core/services/logger'; +import { parse } from 'node-html-parser'; + +export class FaviconService { + private userAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0'; + private relList = [ + 'icon', + 'shortcut icon', + 'apple-touch-icon', + 'apple-touch-icon-precomposed', + 'apple-touch-startup-image', + 'mask-icon', + 'fluid-icon', + ]; + + async getFavicon(url: string): Promise { + try { + return await this.fetchFavicon(this.buildFaviconUrl(url, '/favicon.ico')); + } catch { + logger.debug(`Unable to retrieve favicon from ${url}/favicon.ico`); + } + + const documentText = await this.fetchDocumentText(url); + const faviconPath = this.extractFaviconPath(documentText); + + if (!faviconPath) { + throw new FaviconNotFoundException(`No favicon path found in ${url}`); + } + + return await this.fetchFaviconFromPath(url, faviconPath); + } + + private async fetchFavicon(url: string): Promise { + const response = await this.fetchWithUserAgent(url); + if (!response.ok) { + throw new FaviconNotFoundException(`Request to favicon ${url} failed`); + } + + const blob = await response.blob(); + if (!this.isImage(blob.type) || blob.size === 0) { + throw new FaviconNotFoundException(`Invalid image at ${url}`); + } + + return { + buffer: Buffer.from(await blob.arrayBuffer()), + url: response.url, + type: blob.type, + size: blob.size, + }; + } + + private async fetchDocumentText(url: string): Promise { + const response = await this.fetchWithUserAgent(url); + if (!response.ok) { + throw new FaviconNotFoundException(`Request to ${url} failed`); + } + + return await response.text(); + } + + private extractFaviconPath(html: string): string | undefined { + const document = parse(html); + const link = document + .getElementsByTagName('link') + .find((element) => this.relList.includes(element.getAttribute('rel')!)); + return link?.getAttribute('href'); + } + + private async fetchFaviconFromPath( + baseUrl: string, + path: string + ): Promise { + if (this.isBase64Image(path)) { + const buffer = this.convertBase64ToBuffer(path); + return { + buffer, + type: 'image/x-icon', + size: buffer.length, + url: path, + }; + } + + const faviconUrl = this.buildFaviconUrl(baseUrl, path); + return this.fetchFavicon(faviconUrl); + } + + private buildFaviconUrl(base: string, path: string): string { + const { origin } = new URL(base); + if (path.startsWith('/')) { + return origin + path; + } + + const basePath = this.urlWithoutSearchParams(base); + const baseUrl = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; + const finalUrl = `${baseUrl}/${path}`; + logger.debug(`Built favicon URL: ${finalUrl}`); + return finalUrl; + } + + private urlWithoutSearchParams(url: string): string { + const { protocol, host, pathname } = new URL(url); + return `${protocol}//${host}${pathname}`; + } + + private isImage(type: string): boolean { + return type.startsWith('image/'); + } + + private isBase64Image(data: string): boolean { + return data.startsWith('data:image/'); + } + + private convertBase64ToBuffer(base64: string): Buffer { + return Buffer.from(base64.split(',')[1], 'base64'); + } + + private async fetchWithUserAgent(url: string): Promise { + const headers = new Headers({ 'User-Agent': this.userAgent }); + return fetch(url, { headers }); + } +} diff --git a/app/favicons/types/favicon_type.ts b/app/favicons/types/favicon_type.ts new file mode 100644 index 0000000..d413391 --- /dev/null +++ b/app/favicons/types/favicon_type.ts @@ -0,0 +1,6 @@ +export type Favicon = { + buffer: Buffer; + url: string; + type: string; + size: number; +};