mirror of
https://github.com/skidoodle/hostinfo
synced 2026-04-28 01:27:36 +02:00
Bug fixes and performance improvements
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
export const DnsService = {
|
||||
async resolve(hostname: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://cloudflare-dns.com/dns-query?name=${hostname}&type=A`,
|
||||
{
|
||||
headers: { accept: 'application/dns-json' },
|
||||
credentials: 'omit'
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
return data.Answer?.find((r: any) => r.type === 1)?.data || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
+58
-40
@@ -1,57 +1,75 @@
|
||||
import type { GeoApiResponse, HostInfo } from '@/utils/types';
|
||||
import { StorageService } from '@/utils/storage';
|
||||
import { IpUtils } from '@/utils/ip';
|
||||
import { codes } from '@/utils/codes';
|
||||
|
||||
const CACHE = new Map<string, GeoApiResponse>();
|
||||
import type { GeoData } from '@/utils/types';
|
||||
|
||||
export const GeoService = {
|
||||
async resolve(ip: string): Promise<GeoApiResponse | null> {
|
||||
if (CACHE.has(ip)) return CACHE.get(ip)!;
|
||||
async getGeoData(ip: string): Promise<GeoData> {
|
||||
if (IpUtils.isLocalOrBogon(ip)) {
|
||||
return this.getLocalData(ip);
|
||||
}
|
||||
|
||||
const cached = await StorageService.getGeoCache(ip);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://ip.albert.lol/${ip}`);
|
||||
if (!res.ok) throw new Error(`Geo API Error: ${res.status}`);
|
||||
const res = await fetch(`https://ip.albert.lol/${ip}`, {
|
||||
method: 'GET',
|
||||
cache: 'force-cache',
|
||||
credentials: 'omit'
|
||||
});
|
||||
|
||||
const data: GeoApiResponse = await res.json();
|
||||
CACHE.set(ip, data);
|
||||
if (!res.ok) throw new Error(`API Error ${res.status}`);
|
||||
|
||||
if (CACHE.size > 100) {
|
||||
const firstKey = CACHE.keys().next().value;
|
||||
if (firstKey) CACHE.delete(firstKey);
|
||||
}
|
||||
const raw = await res.json();
|
||||
const data = this.transform(ip, raw);
|
||||
|
||||
await StorageService.setGeoCache(ip, data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Geo lookup failed', error);
|
||||
return null;
|
||||
console.warn('Geo lookup failed for', ip, error);
|
||||
return {
|
||||
ip,
|
||||
countryCode: null,
|
||||
countryName: 'Unknown',
|
||||
city: null,
|
||||
region: null,
|
||||
org: 'Lookup Failed',
|
||||
asn: null,
|
||||
timezone: null,
|
||||
isLocal: false,
|
||||
isBogon: false
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
mapToHostInfo(url: string, ip: string, geo: GeoApiResponse | null): HostInfo {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
const info: HostInfo = {
|
||||
url,
|
||||
domain: urlObj.hostname,
|
||||
loading: false,
|
||||
error: null,
|
||||
isBrowserResource: false,
|
||||
network: {
|
||||
ip,
|
||||
hostname: geo?.hostname || urlObj.hostname,
|
||||
asn: geo?.org?.split(' ')[0] || null,
|
||||
org: geo?.org || 'Unknown Organization',
|
||||
isLocal: false,
|
||||
isBogon: geo?.bogon || false,
|
||||
},
|
||||
location: {
|
||||
countryCode: geo?.country || null,
|
||||
countryName: geo?.country ? (codes[geo.country.toLowerCase()] || geo.country) : null,
|
||||
city: geo?.city || null,
|
||||
region: geo?.region || null,
|
||||
timezone: geo?.timezone || null,
|
||||
}
|
||||
getLocalData(ip: string): GeoData {
|
||||
return {
|
||||
ip,
|
||||
countryCode: null,
|
||||
countryName: 'Local Network',
|
||||
city: null,
|
||||
region: null,
|
||||
org: 'Private Network',
|
||||
asn: null,
|
||||
timezone: null,
|
||||
isLocal: true,
|
||||
isBogon: false
|
||||
};
|
||||
},
|
||||
|
||||
return info;
|
||||
transform(ip: string, apiData: any): GeoData {
|
||||
return {
|
||||
ip,
|
||||
countryCode: apiData.country || null,
|
||||
countryName: apiData.country ? (codes[apiData.country.toLowerCase()] || apiData.country) : null,
|
||||
city: apiData.city || null,
|
||||
region: apiData.region || null,
|
||||
org: apiData.org || null,
|
||||
asn: apiData.org?.split(' ')[0] || null,
|
||||
timezone: apiData.timezone || null,
|
||||
isLocal: false,
|
||||
isBogon: apiData.bogon || false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
+64
-48
@@ -1,63 +1,79 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
|
||||
const ICON_CACHE = new Map<string, Record<string, ImageData>>();
|
||||
|
||||
export const IconService = {
|
||||
async update(tabId: number, countryCode: string | null, isLocal: boolean) {
|
||||
try {
|
||||
const code = isLocal ? 'unknown' : (countryCode ? countryCode.toLowerCase() : 'unknown');
|
||||
async updateIcon(tabId: number, countryCode: string | null, isLocal: boolean) {
|
||||
const code = isLocal ? 'unknown' : (countryCode?.toLowerCase() || 'unknown');
|
||||
const fileName = `${code}.png`;
|
||||
|
||||
const imageData = await this.getIconData(code);
|
||||
await browser.action.setIcon({ tabId, imageData });
|
||||
let success = await this.setIconSafe(tabId, fileName);
|
||||
|
||||
if (!success && code !== 'unknown') {
|
||||
await this.setIconSafe(tabId, 'unknown.png');
|
||||
}
|
||||
|
||||
try {
|
||||
const title = isLocal ? 'Local Resource' : (countryCode ? `Hosted in ${countryCode.toUpperCase()}` : 'Host Info');
|
||||
|
||||
if (typeof chrome !== 'undefined' && chrome.action && chrome.action.setTitle) {
|
||||
chrome.action.setTitle({ tabId, title }, () => {
|
||||
void chrome.runtime.lastError;
|
||||
});
|
||||
} else {
|
||||
await browser.action.setTitle({ tabId, title });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to update icon', e);
|
||||
}
|
||||
},
|
||||
|
||||
async getIconData(code: string): Promise<Record<string, ImageData>> {
|
||||
if (ICON_CACHE.has(code)) return ICON_CACHE.get(code)!;
|
||||
async setIconSafe(tabId: number, fileName: string): Promise<boolean> {
|
||||
let success = await new Promise<boolean>((resolve) => {
|
||||
if (typeof chrome !== 'undefined' && chrome.action && chrome.action.setIcon) {
|
||||
chrome.action.setIcon({ tabId, path: fileName }, () => {
|
||||
resolve(!chrome.runtime.lastError);
|
||||
});
|
||||
} else {
|
||||
browser.action.setIcon({ tabId, path: fileName })
|
||||
.then(() => resolve(true))
|
||||
.catch(() => resolve(false));
|
||||
}
|
||||
});
|
||||
|
||||
const path = `/${code}.webp`;
|
||||
const url = browser.runtime.getURL(path as any);
|
||||
if (success) return true;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('Icon not found');
|
||||
const url = browser.runtime.getURL(`/${fileName}` as any);
|
||||
const res = await fetch(url);
|
||||
|
||||
const blob = await resp.blob();
|
||||
if (!res.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const data = await this.processBitmap(bitmap);
|
||||
const size = Math.max(bitmap.width, bitmap.height);
|
||||
|
||||
ICON_CACHE.set(code, data);
|
||||
return data;
|
||||
} catch {
|
||||
if (code !== 'unknown') return this.getIconData('unknown');
|
||||
throw new Error('Failed to load fallback icon');
|
||||
if (typeof OffscreenCanvas !== 'undefined') {
|
||||
const canvas = new OffscreenCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return false;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.drawImage(bitmap, (size - bitmap.width) / 2, (size - bitmap.height) / 2);
|
||||
const imageData = ctx.getImageData(0, 0, size, size);
|
||||
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
if (typeof chrome !== 'undefined' && chrome.action && chrome.action.setIcon) {
|
||||
chrome.action.setIcon({ tabId, imageData: { [size.toString()]: imageData } }, () => {
|
||||
resolve(!chrome.runtime.lastError);
|
||||
});
|
||||
} else {
|
||||
browser.action.setIcon({ tabId, imageData: { [size.toString()]: imageData } })
|
||||
.then(() => resolve(true))
|
||||
.catch(() => resolve(false));
|
||||
}
|
||||
});
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async processBitmap(bitmap: ImageBitmap): Promise<Record<string, ImageData>> {
|
||||
const canvas = new OffscreenCanvas(128, 128);
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
|
||||
// Center and contain
|
||||
const ratio = Math.min(canvas.width / bitmap.width, canvas.height / bitmap.height);
|
||||
const offsetX = (canvas.width - bitmap.width * ratio) / 2;
|
||||
const offsetY = (canvas.height - bitmap.height * ratio) / 2;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height, offsetX, offsetY, bitmap.width * ratio, bitmap.height * ratio);
|
||||
|
||||
const sizes = [16, 32, 48, 128];
|
||||
const result: Record<string, ImageData> = {};
|
||||
|
||||
for (const size of sizes) {
|
||||
const sCanvas = new OffscreenCanvas(size, size);
|
||||
const sCtx = sCanvas.getContext('2d')!;
|
||||
sCtx.drawImage(canvas, 0, 0, size, size);
|
||||
result[size] = sCtx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
import { IpUtils } from '@/utils/ip';
|
||||
import { StorageService } from '@/utils/storage';
|
||||
import { GeoService } from './geo';
|
||||
import { IconService } from './icon';
|
||||
import type { HostInfo } from '@/utils/types';
|
||||
|
||||
export const Tab = {
|
||||
/**
|
||||
* Handle System/Browser specific pages
|
||||
*/
|
||||
async processSystemPage(tabId: number) {
|
||||
await IconService.update(tabId, null, true);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle Network Errors (DNS, Connection Refused, etc)
|
||||
*/
|
||||
async handleError(tabId: number, url: string, error: string) {
|
||||
let message = error;
|
||||
|
||||
// Normalize error string (remove net:: prefix if present)
|
||||
const code = error.startsWith('net::') ? error.replace('net::', '') : error;
|
||||
|
||||
switch (code) {
|
||||
case 'ERR_NAME_NOT_RESOLVED':
|
||||
case 'NS_ERROR_UNKNOWN_HOST':
|
||||
message = 'DNS Resolution Failed (NXDOMAIN)';
|
||||
break;
|
||||
case 'ERR_CONNECTION_REFUSED':
|
||||
case 'NS_ERROR_CONNECTION_REFUSED':
|
||||
message = 'Connection Refused';
|
||||
break;
|
||||
case 'ERR_INTERNET_DISCONNECTED':
|
||||
case 'NS_ERROR_OFFLINE':
|
||||
message = 'No Internet Connection';
|
||||
break;
|
||||
case 'ERR_CONNECTION_TIMED_OUT':
|
||||
case 'NS_ERROR_NET_TIMEOUT':
|
||||
message = 'Connection Timed Out';
|
||||
break;
|
||||
case 'ERR_ADDRESS_UNREACHABLE':
|
||||
message = 'Address Unreachable';
|
||||
break;
|
||||
default:
|
||||
// Fallback: clean up the code for display (e.g. ERR_SSL_PROTOCOL_ERROR -> Err ssl protocol error)
|
||||
message = code.replace(/_/g, ' ').toLowerCase();
|
||||
message = message.charAt(0).toUpperCase() + message.slice(1);
|
||||
break;
|
||||
}
|
||||
|
||||
const errorInfo: HostInfo = {
|
||||
url,
|
||||
domain: new URL(url).hostname,
|
||||
loading: false,
|
||||
error: message,
|
||||
network: null,
|
||||
location: null,
|
||||
isBrowserResource: false
|
||||
};
|
||||
|
||||
await StorageService.set(tabId, errorInfo);
|
||||
await IconService.update(tabId, 'unknown', false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Main entry point to process a tab's data
|
||||
*/
|
||||
async process(tabId: number, url: string, ip?: string) {
|
||||
// Initial State (Loading)
|
||||
const initialState: HostInfo = {
|
||||
url,
|
||||
domain: new URL(url).hostname,
|
||||
loading: true,
|
||||
error: null,
|
||||
network: null,
|
||||
location: null,
|
||||
isBrowserResource: false
|
||||
};
|
||||
|
||||
// Don't overwrite if we already have data for this exact URL,
|
||||
// but do update if we have a new IP
|
||||
const existing = await StorageService.get(tabId);
|
||||
if (!existing || existing.url !== url) {
|
||||
await StorageService.set(tabId, initialState);
|
||||
}
|
||||
|
||||
// Resolve IP if missing
|
||||
if (!ip) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Local/Private IPs (Bogons)
|
||||
if (IpUtils.isLocalOrBogon(ip)) {
|
||||
const localInfo: HostInfo = {
|
||||
...initialState,
|
||||
loading: false,
|
||||
network: {
|
||||
ip,
|
||||
hostname: 'Local Network',
|
||||
asn: null,
|
||||
org: 'Private Network',
|
||||
isLocal: true,
|
||||
isBogon: false
|
||||
},
|
||||
location: null
|
||||
};
|
||||
await StorageService.set(tabId, localInfo);
|
||||
await IconService.update(tabId, null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch Public Data
|
||||
const geoData = await GeoService.resolve(ip);
|
||||
|
||||
// If Geo lookup fails (e.g. LAN domain with public IP, or API down),
|
||||
// we still want to show the IP we captured.
|
||||
if (!geoData) {
|
||||
const fallbackInfo: HostInfo = {
|
||||
...initialState,
|
||||
loading: false,
|
||||
network: {
|
||||
ip,
|
||||
hostname: null,
|
||||
asn: null,
|
||||
org: 'Unknown',
|
||||
isLocal: false,
|
||||
isBogon: false
|
||||
},
|
||||
location: {
|
||||
countryCode: null,
|
||||
countryName: 'Unknown Location',
|
||||
city: null,
|
||||
region: null,
|
||||
timezone: null
|
||||
}
|
||||
};
|
||||
await StorageService.set(tabId, fallbackInfo);
|
||||
await IconService.update(tabId, 'unknown', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save Final State
|
||||
const finalInfo = GeoService.mapToHostInfo(url, ip, geoData);
|
||||
await StorageService.set(tabId, finalInfo);
|
||||
|
||||
// Determine Icon
|
||||
const asn = finalInfo.network?.asn;
|
||||
let iconCode = finalInfo.location?.countryCode || null;
|
||||
|
||||
if (asn === 'AS13335') {
|
||||
iconCode = 'cloudflare';
|
||||
}
|
||||
|
||||
await IconService.update(tabId, iconCode, false);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user