diff --git a/.env.example b/.env.example index 2518299..7cc0373 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,6 @@ OIDC_REDIRECT_URI=http://localhost:3000/oidc/callback # Document uploads UPLOADS_DIR=./uploads BODY_SIZE_LIMIT=26214400 + +# Company Links favicon fetching (set false to disable outbound fetches in offline dev) +FAVICON_FETCH_ENABLED=true diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 1cd675d..5df1b34 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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) ── export const companyAddressTypeEnum = pgEnum('company_address_type', [ @@ -885,7 +957,10 @@ export const companyLogEventEnum = pgEnum('company_log_event', [ 'document_uploaded', 'document_version_added', 'document_metadata_updated', - 'document_deleted' + 'document_deleted', + 'link_added', + 'link_updated', + 'link_deleted' ]); export const companyLog = pgTable( diff --git a/src/lib/server/favicons/index.ts b/src/lib/server/favicons/index.ts new file mode 100644 index 0000000..ce3896b --- /dev/null +++ b/src/lib/server/favicons/index.ts @@ -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 = /]*\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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/lib/server/links/validate.ts b/src/lib/server/links/validate.ts new file mode 100644 index 0000000..79af4da --- /dev/null +++ b/src/lib/server/links/validate.ts @@ -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; +}