mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-09 23:15:36 +00:00
refactor: favicon controller
This commit is contained in:
@@ -1,28 +1,16 @@
|
|||||||
import { cache } from '#core/lib/cache';
|
import { CacheService } from '#favicons/services/cache_service';
|
||||||
import FaviconNotFoundException from '#favicons/exceptions/favicon_not_found_exception';
|
import { FaviconService } from '#favicons/services/favicons_service';
|
||||||
|
import { Favicon } from '#favicons/types/favicon_type';
|
||||||
import type { HttpContext } from '@adonisjs/core/http';
|
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 {
|
export default class FaviconsController {
|
||||||
private userAgent =
|
private faviconService: FaviconService;
|
||||||
'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 cacheService: CacheService;
|
||||||
private relList = [
|
|
||||||
'icon',
|
constructor(faviconService: FaviconService, cacheService: CacheService) {
|
||||||
'shortcut icon',
|
this.faviconService = faviconService;
|
||||||
'apple-touch-icon',
|
this.cacheService = cacheService;
|
||||||
'apple-touch-icon-precomposed',
|
}
|
||||||
'apple-touch-startup-image',
|
|
||||||
'mask-icon',
|
|
||||||
'fluid-icon',
|
|
||||||
];
|
|
||||||
|
|
||||||
async index(ctx: HttpContext) {
|
async index(ctx: HttpContext) {
|
||||||
const url = ctx.request.qs()?.url;
|
const url = ctx.request.qs()?.url;
|
||||||
@@ -30,128 +18,12 @@ export default class FaviconsController {
|
|||||||
throw new Error('Missing URL');
|
throw new Error('Missing URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheNs = cache.namespace('favicon');
|
const favicon = await this.cacheService.getOrSetFavicon(url, () =>
|
||||||
const favicon = await cacheNs.getOrSet({
|
this.faviconService.getFavicon(url)
|
||||||
key: url,
|
);
|
||||||
ttl: '1h',
|
|
||||||
factory: () => this.tryGetFavicon(url),
|
|
||||||
});
|
|
||||||
return this.sendImage(ctx, favicon);
|
return this.sendImage(ctx, favicon);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryGetFavicon(url: string): Promise<Favicon> {
|
|
||||||
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<Favicon> {
|
|
||||||
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<string> {
|
|
||||||
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<Favicon> {
|
|
||||||
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<Response> {
|
|
||||||
const headers = new Headers({ 'User-Agent': this.userAgent });
|
|
||||||
return fetch(url, { headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendImage(ctx: HttpContext, { buffer, type, size }: Favicon) {
|
private sendImage(ctx: HttpContext, { buffer, type, size }: Favicon) {
|
||||||
ctx.response.header('Content-Type', type);
|
ctx.response.header('Content-Type', type);
|
||||||
ctx.response.header('Content-Length', size.toString());
|
ctx.response.header('Content-Length', size.toString());
|
||||||
|
|||||||
17
app/favicons/services/cache_service.ts
Normal file
17
app/favicons/services/cache_service.ts
Normal file
@@ -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<Favicon>
|
||||||
|
): Promise<Favicon> {
|
||||||
|
return this.cacheNs.getOrSet({
|
||||||
|
key: url,
|
||||||
|
ttl: '1h',
|
||||||
|
factory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/favicons/services/favicons_service.ts
Normal file
124
app/favicons/services/favicons_service.ts
Normal file
@@ -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<Favicon> {
|
||||||
|
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<Favicon> {
|
||||||
|
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<string> {
|
||||||
|
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<Favicon> {
|
||||||
|
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<Response> {
|
||||||
|
const headers = new Headers({ 'User-Agent': this.userAgent });
|
||||||
|
return fetch(url, { headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/favicons/types/favicon_type.ts
Normal file
6
app/favicons/types/favicon_type.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type Favicon = {
|
||||||
|
buffer: Buffer;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user