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;
+}