184 lines
4.9 KiB
TypeScript
184 lines
4.9 KiB
TypeScript
import { logger } from '@/utils/logger';
|
|
import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
import icoToPng from 'ico-to-png';
|
|
import sharp from 'sharp';
|
|
|
|
import { getClientIp } from '@/utils/get-client-ip';
|
|
import { createHash } from '@openpanel/common/server';
|
|
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
|
import { getGeoLocation } from '@openpanel/geo';
|
|
import { cacheable, getCache, getRedisCache } from '@openpanel/redis';
|
|
|
|
interface GetFaviconParams {
|
|
url: string;
|
|
}
|
|
|
|
async function getImageBuffer(url: string) {
|
|
try {
|
|
const res = await fetch(url);
|
|
const contentType = res.headers.get('content-type');
|
|
|
|
if (!contentType?.includes('image')) {
|
|
return null;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
return null;
|
|
}
|
|
|
|
if (contentType === 'image/x-icon' || url.endsWith('.ico')) {
|
|
const arrayBuffer = await res.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
return await icoToPng(buffer, 30);
|
|
}
|
|
|
|
return await sharp(await res.arrayBuffer())
|
|
.resize(30, 30, {
|
|
fit: 'cover',
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
} catch (error) {
|
|
logger.error('Failed to get image from url', {
|
|
error,
|
|
url,
|
|
});
|
|
}
|
|
}
|
|
|
|
const imageExtensions = ['svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'];
|
|
|
|
export async function getFavicon(
|
|
request: FastifyRequest<{
|
|
Querystring: GetFaviconParams;
|
|
}>,
|
|
reply: FastifyReply,
|
|
) {
|
|
function sendBuffer(buffer: Buffer, cacheKey?: string) {
|
|
if (cacheKey) {
|
|
getRedisCache().set(`favicon:${cacheKey}`, buffer.toString('base64'));
|
|
}
|
|
reply.header('Cache-Control', 'public, max-age=604800');
|
|
reply.header('Expires', new Date(Date.now() + 604800000).toUTCString());
|
|
reply.type('image/png');
|
|
return reply.send(buffer);
|
|
}
|
|
|
|
if (!request.query.url) {
|
|
return reply.status(404).send('Not found');
|
|
}
|
|
|
|
const url = decodeURIComponent(request.query.url);
|
|
|
|
if (imageExtensions.find((ext) => url.endsWith(ext))) {
|
|
const cacheKey = createHash(url, 32);
|
|
const cache = await getRedisCache().get(`favicon:${cacheKey}`);
|
|
if (cache) {
|
|
return sendBuffer(Buffer.from(cache, 'base64'));
|
|
}
|
|
const buffer = await getImageBuffer(url);
|
|
if (buffer && buffer.byteLength > 0) {
|
|
return sendBuffer(buffer, cacheKey);
|
|
}
|
|
}
|
|
|
|
const { hostname } = new URL(url);
|
|
const cache = await getRedisCache().get(`favicon:${hostname}`);
|
|
|
|
if (cache) {
|
|
return sendBuffer(Buffer.from(cache, 'base64'));
|
|
}
|
|
|
|
const meta = await parseUrlMeta(url);
|
|
if (meta?.favicon) {
|
|
const buffer = await getImageBuffer(meta.favicon);
|
|
if (buffer && buffer.byteLength > 0) {
|
|
return sendBuffer(buffer, hostname);
|
|
}
|
|
}
|
|
|
|
const buffer = await getImageBuffer(
|
|
'https://www.iconsdb.com/icons/download/orange/warning-128.png',
|
|
);
|
|
if (buffer && buffer.byteLength > 0) {
|
|
return sendBuffer(buffer, hostname);
|
|
}
|
|
|
|
return reply.status(404).send('Not found');
|
|
}
|
|
|
|
export async function clearFavicons(
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
) {
|
|
const keys = await getRedisCache().keys('favicon:*');
|
|
for (const key of keys) {
|
|
await getRedisCache().del(key);
|
|
}
|
|
return reply.status(404).send('OK');
|
|
}
|
|
|
|
export async function ping(
|
|
request: FastifyRequest<{
|
|
Body: {
|
|
domain: string;
|
|
count: number;
|
|
};
|
|
}>,
|
|
reply: FastifyReply,
|
|
) {
|
|
try {
|
|
await ch.insert({
|
|
table: TABLE_NAMES.self_hosting,
|
|
values: [
|
|
{
|
|
domain: request.body.domain,
|
|
count: request.body.count,
|
|
created_at: formatClickhouseDate(new Date(), true),
|
|
},
|
|
],
|
|
format: 'JSONEachRow',
|
|
});
|
|
reply.status(200).send({
|
|
message: 'Success',
|
|
count: request.body.count,
|
|
domain: request.body.domain,
|
|
});
|
|
} catch (error) {
|
|
request.log.error('Failed to insert ping', {
|
|
error,
|
|
});
|
|
reply.status(500).send({
|
|
error: 'Failed to insert ping',
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function stats(request: FastifyRequest, reply: FastifyReply) {
|
|
const res = await getCache('api:stats', 60 * 60, async () => {
|
|
const projects = await chQuery<{ project_id: string; count: number }>(
|
|
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`,
|
|
);
|
|
const last24h = await chQuery<{ count: number }>(
|
|
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`,
|
|
);
|
|
return { projects, last24hCount: last24h[0]?.count || 0 };
|
|
});
|
|
|
|
reply.status(200).send({
|
|
projectsCount: res.projects.length,
|
|
eventsCount: res.projects.reduce((acc, { count }) => acc + count, 0),
|
|
eventsLast24hCount: res.last24hCount,
|
|
});
|
|
}
|
|
|
|
export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
|
const ip = getClientIp(request);
|
|
if (!ip) {
|
|
return reply.status(400).send('Bad Request');
|
|
}
|
|
const geo = await getGeoLocation(ip);
|
|
return reply.status(200).send(geo);
|
|
}
|