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:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user