Add companyLinks schema, favicon helper with SSRF guard, URL validator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 11:45:08 +07:00
parent 5d9c0f0249
commit 84a98efd6e
4 changed files with 323 additions and 1 deletions
+3
View File
@@ -18,3 +18,6 @@ OIDC_REDIRECT_URI=http://localhost:3000/oidc/callback
# Document uploads # Document uploads
UPLOADS_DIR=./uploads UPLOADS_DIR=./uploads
BODY_SIZE_LIMIT=26214400 BODY_SIZE_LIMIT=26214400
# Company Links favicon fetching (set false to disable outbound fetches in offline dev)
FAVICON_FETCH_ENABLED=true
+76 -1
View File
@@ -735,6 +735,78 @@ export const companyDocumentVersions = pgTable(
] ]
); );
// ── Company Links (internal tool & bookmark hub) ──────
export const companyLinkCategoryEnum = pgEnum('company_link_category', [
'internal_tool',
'communication',
'social_media',
'analytics',
'banking',
'government',
'storage',
'marketing',
'development',
'website',
'other'
]);
export const companyLinks = pgTable(
'company_links',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
category: companyLinkCategoryEnum('category').notNull(),
customLabel: text('custom_label'),
title: text('title').notNull(),
url: text('url').notNull(),
description: text('description'),
faviconUrl: text('favicon_url'),
faviconFetchedAt: timestamp('favicon_fetched_at', { withTimezone: true }),
sortOrder: integer('sort_order').notNull().default(0),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('company_links_company_cat_sort_idx').on(table.companyId, table.category, table.sortOrder)
]
);
export const userCompanyLinks = pgTable(
'user_company_links',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
category: companyLinkCategoryEnum('category').notNull(),
customLabel: text('custom_label'),
title: text('title').notNull(),
url: text('url').notNull(),
description: text('description'),
faviconUrl: text('favicon_url'),
faviconFetchedAt: timestamp('favicon_fetched_at', { withTimezone: true }),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('user_company_links_user_company_idx').on(
table.userId,
table.companyId,
table.category,
table.sortOrder
)
]
);
// ── Company Profile (bank accounts, cards, addresses) ── // ── Company Profile (bank accounts, cards, addresses) ──
export const companyAddressTypeEnum = pgEnum('company_address_type', [ export const companyAddressTypeEnum = pgEnum('company_address_type', [
@@ -885,7 +957,10 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'document_uploaded', 'document_uploaded',
'document_version_added', 'document_version_added',
'document_metadata_updated', 'document_metadata_updated',
'document_deleted' 'document_deleted',
'link_added',
'link_updated',
'link_deleted'
]); ]);
export const companyLog = pgTable( export const companyLog = pgTable(
+210
View File
@@ -0,0 +1,210 @@
import { env } from '$env/dynamic/private';
import dns from 'node:dns/promises';
import net from 'node:net';
const FETCH_TIMEOUT_MS = 2000;
const MAX_BYTES = 64 * 1024;
const HTML_ICON_REL_RE = /<link\b[^>]*\brel=["']([^"']+)["'][^>]*>/gi;
const HREF_ATTR_RE = /\bhref=["']([^"']+)["']/i;
export interface FaviconResult {
dataUrl: string;
fetchedAt: Date;
}
function fetchEnabled(): boolean {
const v = env.FAVICON_FETCH_ENABLED;
if (v === undefined || v === null || v === '') return true;
return v.toLowerCase() !== 'false' && v !== '0';
}
export function isPrivateOrLoopback(ip: string): boolean {
if (net.isIPv4(ip)) {
const [a, b] = ip.split('.').map(Number);
if (a === 0) return true;
if (a === 10) return true;
if (a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
return false;
}
if (net.isIPv6(ip)) {
const lower = ip.toLowerCase();
if (lower === '::1' || lower === '::') return true;
if (lower.startsWith('fe80:') || lower.startsWith('fe80::')) return true;
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true;
if (lower.startsWith('::ffff:')) {
const v4 = lower.slice(7);
if (net.isIPv4(v4)) return isPrivateOrLoopback(v4);
}
return false;
}
return true;
}
async function resolvePublicIp(hostname: string): Promise<string> {
if (net.isIP(hostname)) {
if (isPrivateOrLoopback(hostname)) {
throw new Error(`Host ${hostname} is private or loopback`);
}
return hostname;
}
let ips: string[] = [];
try {
ips = await dns.resolve4(hostname);
} catch {
// ignore, try v6
}
if (ips.length === 0) {
try {
ips = await dns.resolve6(hostname);
} catch {
throw new Error(`Could not resolve host ${hostname}`);
}
}
if (ips.length === 0) throw new Error(`Host ${hostname} has no A/AAAA records`);
for (const ip of ips) {
if (isPrivateOrLoopback(ip)) {
throw new Error(`Host ${hostname} resolves to a private address`);
}
}
return ips[0];
}
async function safeFetch(targetUrl: URL): Promise<Response | null> {
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') return null;
try {
await resolvePublicIp(targetUrl.hostname);
} catch {
return null;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(targetUrl.toString(), {
method: 'GET',
redirect: 'manual',
signal: controller.signal,
headers: {
'User-Agent': 'buildfor-life-budget/1.0 (favicon fetcher)',
Accept: 'text/html,image/*;q=0.8,*/*;q=0.5'
}
});
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
if (!loc) return null;
let next: URL;
try {
next = new URL(loc, targetUrl);
} catch {
return null;
}
if (next.protocol !== 'http:' && next.protocol !== 'https:') return null;
try {
await resolvePublicIp(next.hostname);
} catch {
return null;
}
return safeFetch(next);
}
return res;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
async function readCappedBytes(res: Response): Promise<Buffer | null> {
const reader = res.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.length;
if (total > MAX_BYTES) {
try {
await reader.cancel();
} catch {
// ignore
}
return null;
}
chunks.push(value);
}
}
} catch {
return null;
}
return Buffer.concat(chunks.map((c) => Buffer.from(c)));
}
async function fetchAsDataUrl(targetUrl: URL): Promise<string | null> {
const res = await safeFetch(targetUrl);
if (!res || !res.ok) return null;
const contentType = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
if (!contentType.startsWith('image/')) return null;
const buf = await readCappedBytes(res);
if (!buf || buf.length === 0) return null;
return `data:${contentType};base64,${buf.toString('base64')}`;
}
function extractIconHrefs(html: string): string[] {
const hrefs: string[] = [];
for (const match of html.matchAll(HTML_ICON_REL_RE)) {
const rel = match[1].toLowerCase();
if (
rel.includes('icon') ||
rel.includes('shortcut icon') ||
rel.includes('apple-touch-icon')
) {
const hrefMatch = match[0].match(HREF_ATTR_RE);
if (hrefMatch) hrefs.push(hrefMatch[1]);
}
}
return hrefs;
}
export async function fetchFavicon(rawUrl: string): Promise<FaviconResult | null> {
if (!fetchEnabled()) return null;
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
const origin = new URL(parsed.origin);
const direct = await fetchAsDataUrl(new URL('/favicon.ico', origin));
if (direct) return { dataUrl: direct, fetchedAt: new Date() };
const htmlRes = await safeFetch(origin);
if (!htmlRes || !htmlRes.ok) return null;
const ct = (htmlRes.headers.get('content-type') ?? '').toLowerCase();
if (!ct.includes('text/html') && !ct.includes('application/xhtml')) return null;
const htmlBuf = await readCappedBytes(htmlRes);
if (!htmlBuf) return null;
const html = htmlBuf.toString('utf8');
const hrefs = extractIconHrefs(html);
for (const href of hrefs) {
let iconUrl: URL;
try {
iconUrl = new URL(href, origin);
} catch {
continue;
}
const data = await fetchAsDataUrl(iconUrl);
if (data) return { dataUrl: data, fetchedAt: new Date() };
}
return null;
}
+34
View File
@@ -0,0 +1,34 @@
export const URL_MAX_LENGTH = 2048;
export class InvalidLinkUrlError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidLinkUrlError';
}
}
export function validateLinkUrl(raw: string): string {
if (typeof raw !== 'string') {
throw new InvalidLinkUrlError('URL is required');
}
const trimmed = raw.trim();
if (trimmed.length === 0) {
throw new InvalidLinkUrlError('URL is required');
}
if (trimmed.length > URL_MAX_LENGTH) {
throw new InvalidLinkUrlError(`URL must be at most ${URL_MAX_LENGTH} characters`);
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new InvalidLinkUrlError('URL is malformed');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new InvalidLinkUrlError('URL must use http or https');
}
return parsed.href;
}