Fetch to fill the fields, then review or edit them before saving.
+${esc(err)}
` : ""} `; }); - return ``; + return ``; } /** @@ -58,4 +68,13 @@ export function renderProfileFields({ export const PROFILE_ENHANCE_SCRIPT = `(()=>{const free=new Set(${JSON.stringify([...FREEMAIL])}); document.querySelectorAll('input[name="email"]').forEach(e=>{const f=e.form;const d=f&&f.querySelector('input[name="company_domain"]');if(!d)return; e.addEventListener('blur',()=>{if(d.value.trim())return;const h=(e.value.split('@')[1]||'').trim().toLowerCase(); -if(h.includes('.')&&!free.has(h)&&!/^(yahoo|hotmail|outlook|live|gmx|yandex)\\./.test(h))d.value=h;});});})();`; +if(h.includes('.')&&!free.has(h)&&!/^(yahoo|hotmail|outlook|live|gmx|yandex)\\./.test(h))d.value=h;});}); +document.querySelectorAll('[data-pfs-openprofile-import]').forEach(box=>{if(box.dataset.pfsBound)return;box.dataset.pfsBound='1'; +const input=box.querySelector('[data-pfs-openprofile-url]');const button=box.querySelector('[data-pfs-openprofile-button]');const status=box.querySelector('[data-pfs-openprofile-status]');const endpoint=box.dataset.endpoint||'/api/v1/me/profile';if(!input||!button||!status)return; +input.addEventListener('input',()=>{button.disabled=!input.value.trim();});button.addEventListener('click',async()=>{button.disabled=true;button.textContent='Fetching…';status.textContent='';status.setAttribute('role','status'); +try{const profileUrl=new URL(input.value.trim());if(profileUrl.protocol!=='https:')throw new Error('Use an HTTPS OpenProfile URL.'); +const target=new URL(endpoint,location.href);target.searchParams.set('openprofile',profileUrl.href);const response=await fetch(target,{credentials:'same-origin',cache:'no-store'});const body=await response.json();if(!response.ok)throw new Error(body.error||'Could not fetch that OpenProfile.md.'); +const fields=box.closest('form')||box.parentElement;let count=0;Object.entries(body.profile||{}).forEach(([name,value])=>{if(typeof value!=='string'||!value.trim())return;const field=fields.querySelector('[name="'+name+'"]');if(!field)return;field.value=value;field.dispatchEvent(new Event('input',{bubbles:true}));field.dispatchEvent(new Event('change',{bubbles:true}));count++;}); +if(!count)throw new Error('No contact details were found in that OpenProfile.md.');status.textContent='Fetched. Review or edit the fields before saving.'; +}catch(error){status.textContent=error instanceof Error?error.message:'Could not fetch that OpenProfile.md.';status.setAttribute('role','alert');} +finally{button.textContent='Fetch and prefill';button.disabled=!input.value.trim();}});});})();`; diff --git a/src/index.js b/src/index.js index a08b8d1..2e22e3b 100644 --- a/src/index.js +++ b/src/index.js @@ -22,6 +22,6 @@ export { ProfileValidationError, } from "./validate.js"; export { createProfileStore, migrate, schemaSql, adapt } from "./store.js"; -export { createProfileHandlers, mountProfileRoutes, saveSignupProfile } from "./handlers.js"; +export { createProfileHandlers, mountProfileRoutes, saveSignupProfile, saveOpenProfileContact } from "./handlers.js"; export { renderProfileFields, PROFILE_ENHANCE_SCRIPT } from "./html.js"; export { toCsv, csvHeader, csvRow, csvCell } from "./csv.js"; diff --git a/src/openprofile-import.js b/src/openprofile-import.js new file mode 100644 index 0000000..ff1dbd0 --- /dev/null +++ b/src/openprofile-import.js @@ -0,0 +1,154 @@ +import { + accounts, + identityMap, + parseOpenProfile, + sectionKeys, + sections, +} from '@profullstack/openprofile'; +import { NORMALIZERS, domainFromEmail, splitFullName } from './normalize.js'; +import { readBoundedText, safeFetchPublicUrl } from './safe-fetch.js'; + +export class OpenProfileImportError extends Error { + constructor(message, status = 422) { + super(message); + this.name = 'OpenProfileImportError'; + this.status = status; + } +} + +function keyOf(key) { + return key.toLowerCase().replace(/[_-]+/g, ' ').trim(); +} + +function markdownTarget(value) { + const link = value.trim().match(/^\[[^\]]*\]\((https?:\/\/[^)]+|mailto:[^)]+)\)$/i); + return link?.[1] ?? value.trim(); +} + +function markdownText(value) { + const link = value.trim().match(/^\[([^\]]*)\]\([^)]+\)$/); + return (link?.[1] ?? value).replace(/\*\*/g, '').trim(); +} + +function emailAddress(value) { + const target = markdownTarget(value); + return target.toLowerCase().startsWith('mailto:') ? target.slice(7).split('?')[0] ?? '' : target; +} + +function contactValues(doc) { + const identity = Object.fromEntries(Object.entries(identityMap(doc)).map(([key, value]) => [keyOf(key), value])); + const contact = Object.assign( + {}, + ...sections(doc, 'contact').map((section) => + Object.fromEntries(Object.entries(sectionKeys(section.body)).map(([key, value]) => [keyOf(key), value])), + ), + ); + const all = { ...contact, ...identity }; + const value = (...keys) => { + for (const key of keys) { + const found = all[keyOf(key)]?.trim(); + if (found) return found; + } + return ''; + }; + return { + email: value('email', 'contact email'), + first_name: value('first name', 'given name', 'given'), + last_name: value('last name', 'family name', 'surname'), + company_domain: value( + 'company domain', + 'company_domain', + 'organization domain', + 'organisation domain', + 'company website', + 'organization website', + 'organisation website', + 'employer website', + 'company', + 'organization', + 'organisation', + ), + job_title: value('job title', 'job_title', 'title', 'position', 'role'), + linkedin_url: value('linkedin', 'linkedin url', 'linkedin profile'), + name: doc.name ?? '', + }; +} + +/** Parse OpenProfile.md into the shared, validated profile fields. */ +export function contactFromOpenProfile(markdown) { + const doc = parseOpenProfile(markdown); + const source = contactValues(doc); + const profile = {}; + const put = (field, raw, mode = 'text') => { + if (!raw) return; + const candidate = mode === 'target' ? markdownTarget(raw) : mode === 'email' ? emailAddress(raw) : markdownText(raw); + try { + const normalized = NORMALIZERS[field](candidate); + if (normalized) profile[field] = normalized; + } catch { + // Skip an unfamiliar value while keeping other usable fields. + } + }; + + put('email', source.email, 'email'); + put('first_name', source.first_name); + put('last_name', source.last_name); + + const name = splitFullName(markdownText(source.name)); + if (!profile.first_name && name.first_name) put('first_name', name.first_name); + if (!profile.last_name && name.last_name) put('last_name', name.last_name); + + put('company_domain', source.company_domain, 'target'); + if (!profile.company_domain && profile.email) { + const inferred = domainFromEmail(profile.email); + if (inferred) profile.company_domain = inferred; + } + + put('job_title', source.job_title); + put('linkedin_url', source.linkedin_url, 'target'); + if (!profile.linkedin_url) { + for (const account of accounts(doc)) { + let linkedin = /linkedin/i.test(account.label ?? ''); + if (!linkedin) { + try { + linkedin = /(^|\.)linkedin\.com$/i.test(new URL(account.url).hostname); + } catch { + linkedin = false; + } + } + if (!linkedin) continue; + put('linkedin_url', account.url, 'target'); + if (profile.linkedin_url) break; + } + } + + return profile; +} + +/** Fetch a bounded public OpenProfile and return only normalized contact fields. */ +export async function fetchOpenProfileContact(url, { fetcher, lookup } = {}) { + if (typeof url !== 'string' || !url.trim() || url.length > 2_048) { + throw new OpenProfileImportError('Enter a valid OpenProfile.md URL.', 400); + } + const fetched = await safeFetchPublicUrl(url, { ...(fetcher ? { fetcher } : {}), ...(lookup ? { lookup } : {}) }); + if (!fetched.ok) { + if (fetched.kind === 'blocked') { + throw new OpenProfileImportError('Use a public HTTPS URL for an OpenProfile.md file.', 400); + } + throw new OpenProfileImportError('Could not fetch that OpenProfile.md URL.', 422); + } + if (!fetched.response.ok) { + void fetched.response.body?.cancel().catch(() => {}); + throw new OpenProfileImportError('Could not fetch that OpenProfile.md URL.', 422); + } + const read = await readBoundedText(fetched.response); + if (!read.ok && read.reason === 'too-large') { + throw new OpenProfileImportError('That OpenProfile.md is larger than 256 KB.', 413); + } + if (!read.ok) throw new OpenProfileImportError('Could not read that OpenProfile.md file.', 422); + const profile = contactFromOpenProfile(read.text); + if (Object.keys(profile).length === 0) { + throw new OpenProfileImportError('No contact details were found in that OpenProfile.md.', 422); + } + return profile; +} diff --git a/src/react.js b/src/react.js index 7c2a241..1469443 100644 --- a/src/react.js +++ b/src/react.js @@ -7,7 +7,7 @@ * Uncontrolled by default (inputs post with the form, or read them with * FormData); pass `values` and `onChange(values)` to control it. */ -import { createElement as h, useRef } from "react"; +import { createElement as h, useRef, useState } from "react"; import { COLUMNS, DEFAULT_REQUIRED, FIELD_BY_NAME } from "./fields.js"; import { domainFromEmail } from "./normalize.js"; @@ -22,9 +22,60 @@ export function ProfileFields({ className = "pfs-profile", labels = {}, disabled = false, + importOpenProfile = false, + importEndpoint = "/api/v1/me/profile", + importOpenProfileFetcher, }) { const domainRef = useRef(null); + const fieldsetRef = useRef(null); const controlled = values !== undefined && typeof onChange === "function"; + const [profileUrl, setProfileUrl] = useState(""); + const [importing, setImporting] = useState(false); + const [importMessage, setImportMessage] = useState(null); + + const fetchOpenProfile = async () => { + setImporting(true); + setImportMessage(null); + try { + const url = new URL(profileUrl.trim()); + if (url.protocol !== "https:") throw new Error("Use an HTTPS OpenProfile URL."); + let profile; + if (typeof importOpenProfileFetcher === "function") { + profile = await importOpenProfileFetcher(url.toString()); + } else { + const endpoint = new URL(importEndpoint, window.location.href); + endpoint.searchParams.set("openprofile", url.toString()); + const response = await fetch(endpoint.toString(), { credentials: "same-origin", cache: "no-store" }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || "Could not fetch that OpenProfile.md."); + profile = body.profile; + } + const imported = Object.fromEntries( + COLUMNS.filter((name) => typeof profile?.[name] === "string" && profile[name].trim()) + .map((name) => [name, profile[name]]), + ); + if (Object.keys(imported).length === 0) throw new Error("No contact details were found in that OpenProfile.md."); + + if (controlled) onChange({ ...values, ...imported }); + else { + for (const [name, value] of Object.entries(imported)) { + const input = fieldsetRef.current?.querySelector(`[name="${name}"]`); + if (!input) continue; + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + } + } + setImportMessage({ ok: true, text: "Fetched. Review or edit the fields before saving." }); + } catch (error) { + setImportMessage({ + ok: false, + text: error instanceof Error ? error.message : "Could not fetch that OpenProfile.md.", + }); + } finally { + setImporting(false); + } + }; const onEmailBlur = (e) => { const derived = domainFromEmail(e.target.value); @@ -38,7 +89,65 @@ export function ProfileFields({ return h( "fieldset", - { className, disabled }, + { className, disabled: disabled || importing, ref: fieldsetRef }, + importOpenProfile + ? h( + "div", + { className: `${className}__import`, style: { display: "grid", gap: "0.5rem", marginBottom: "1rem" } }, + h("label", { htmlFor: `${idPrefix}openprofile-url` }, "Prefill from OpenProfile.md"), + h( + "div", + { style: { display: "flex", flexWrap: "wrap", gap: "0.5rem" } }, + h("input", { + id: `${idPrefix}openprofile-url`, + className: `${className}__import-url`, + type: "url", + autoComplete: "url", + placeholder: "https://example.com/.well-known/openprofile.md", + value: profileUrl, + onChange: (event) => setProfileUrl(event.target.value), + style: { + flex: "1 1 18rem", + minWidth: 0, + padding: "0.5rem 0.75rem", + border: "1px solid currentColor", + borderRadius: "0.375rem", + background: "transparent", + color: "inherit", + font: "inherit", + }, + }), + h( + "button", + { + type: "button", + onClick: () => void fetchOpenProfile(), + disabled: !profileUrl.trim(), + style: { + padding: "0.5rem 0.75rem", + border: "1px solid currentColor", + borderRadius: "0.375rem", + background: "transparent", + color: "inherit", + font: "inherit", + cursor: profileUrl.trim() ? "pointer" : "not-allowed", + }, + }, + importing ? "Fetching…" : "Fetch and prefill", + ), + ), + h( + "p", + { + className: `${className}__import-status`, + role: importMessage?.ok === false ? "alert" : "status", + "aria-live": "polite", + style: { minHeight: "1.25em", margin: 0 }, + }, + importMessage?.text ?? "Fetch to fill the fields, then review or edit them before saving.", + ), + ) + : null, fields.map((name) => { const f = FIELD_BY_NAME[name]; const id = `${idPrefix}${name}`; diff --git a/src/safe-fetch.js b/src/safe-fetch.js new file mode 100644 index 0000000..542c4c3 --- /dev/null +++ b/src/safe-fetch.js @@ -0,0 +1,250 @@ +/** + * Fetch a public HTTPS document supplied by an authenticated profile owner. + * Every redirect is checked again, DNS answers must all be public, and the + * request uses those validated addresses for its TLS connection. Callers must + * parse/validate the document instead of returning its raw body. + */ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; +import https from 'node:https'; +import { Readable } from 'node:stream'; + +const BLOCKED_HOSTS = new Set([ + 'localhost', + 'metadata', + 'metadata.google.internal', + 'metadata.goog', + 'instance-data', + 'instance-data.ec2.internal', +]); + +const BLOCKED_V4 = [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +]; + +function ipv4Int(address) { + const parts = address.split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null; + return parts.reduce((value, part) => value * 256 + part, 0) >>> 0; +} + +function blockedV4(address) { + const value = ipv4Int(address); + if (value === null) return true; + return BLOCKED_V4.some(([base, bits]) => { + const baseValue = ipv4Int(base); + const mask = (0xffffffff << (32 - bits)) >>> 0; + return (value & mask) === (baseValue & mask); + }); +} + +function expandV6(address) { + let text = address.toLowerCase().replace(/^\[|\]$/g, ''); + const dotted = text.match(/(\d+\.\d+\.\d+\.\d+)$/); + if (dotted) { + const v4 = ipv4Int(dotted[1]); + if (v4 === null) return null; + const hi = ((v4 >>> 16) & 0xffff).toString(16); + const lo = (v4 & 0xffff).toString(16); + text = `${text.slice(0, dotted.index)}${hi}:${lo}`; + } + const halves = text.split('::'); + if (halves.length > 2) return null; + const parse = (part) => { + if (!part) return []; + const groups = part.split(':'); + if (groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return null; + return groups.map((group) => Number.parseInt(group, 16)); + }; + const head = parse(halves[0]); + const tail = halves.length === 2 ? parse(halves[1]) : []; + if (head === null || tail === null) return null; + if (halves.length === 1) return head.length === 8 ? head : null; + const zeros = 8 - head.length - tail.length; + return zeros < 1 ? null : [...head, ...new Array(zeros).fill(0), ...tail]; +} + +function blockedV6(address) { + const groups = expandV6(address); + if (!groups) return true; + if (groups.every((group) => group === 0)) return true; + if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) return true; + if ((groups[0] & 0xfe00) === 0xfc00) return true; // unique local + if ((groups[0] & 0xffc0) === 0xfe80) return true; // link local + if ((groups[0] & 0xff00) === 0xff00) return true; // multicast + if ((groups[0] & 0xe000) !== 0x2000) return true; // only global unicast + if (groups[0] === 0x2002) return true; // 6to4 embeds IPv4 destinations + if (groups[0] === 0x2001 && groups[1] === 0) return true; // Teredo embeds IPv4 destinations + if (groups[0] === 0x2001 && groups[1] === 0x0db8) return true; // documentation + + const zeroPrefix = groups.slice(0, 5).every((group) => group === 0); + if (zeroPrefix && (groups[5] === 0xffff || groups[5] === 0)) { + const address4 = [groups[6] >> 8, groups[6] & 255, groups[7] >> 8, groups[7] & 255].join('.'); + return blockedV4(address4); + } + // Well-known NAT64 embeds IPv4 in the last 32 bits. + if (groups.slice(0, 6).every((group, index) => group === [0x0064, 0xff9b, 0, 0, 0, 0][index])) { + const address4 = [groups[6] >> 8, groups[6] & 255, groups[7] >> 8, groups[7] & 255].join('.'); + return blockedV4(address4); + } + return false; +} + +function blockedAddress(address) { + const family = isIP(address.replace(/^\[|\]$/g, '')); + if (family === 4) return blockedV4(address); + if (family === 6) return blockedV6(address); + return true; +} + +async function resolvePublicUrl(raw, lookup = dnsLookup) { + let url; + try { + url = new URL(raw); + } catch { + return null; + } + if (url.protocol !== 'https:' || url.username || url.password) return null; + const hostname = url.hostname.toLowerCase().replace(/\.$/, ''); + if (!hostname || BLOCKED_HOSTS.has(hostname) || /\.(localhost|internal|local)$/.test(hostname)) return null; + + const family = isIP(hostname.replace(/^\[|\]$/g, '')); + if (family) return blockedAddress(hostname) ? null : { url, addresses: [{ address: hostname, family }] }; + + let addresses; + try { + addresses = await lookup(hostname, { all: true, verbatim: true }); + } catch { + return null; + } + if (!addresses?.length || addresses.some(({ address }) => blockedAddress(address))) return null; + return { url, addresses }; +} + +export async function checkPublicUrl(raw, lookup = dnsLookup) { + return (await resolvePublicUrl(raw, lookup))?.url ?? null; +} + +function pinnedHttpsRequest(url, addresses, signal) { + return new Promise((resolve, reject) => { + let addressIndex = 0; + const lookup = (hostname, options, callback) => { + const requestedHost = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); + const expectedHost = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); + if (requestedHost !== expectedHost) { + callback(new Error('Unexpected hostname during pinned request')); + return; + } + if (options?.all) { + callback(null, addresses); + return; + } + const address = addresses[addressIndex % addresses.length]; + addressIndex += 1; + callback(null, address.address, address.family); + }; + const request = https.request(url, { + method: 'GET', + headers: { Accept: 'text/markdown, text/plain;q=0.9' }, + lookup, + signal, + maxHeaderSize: 16 * 1024, + }, (response) => { + const status = response.statusCode ?? 502; + const body = [204, 205, 304].includes(status) ? null : Readable.toWeb(response); + resolve(new Response(body, { status, headers: response.headers })); + }); + request.once('error', reject); + request.end(); + }); +} + +export async function safeFetchPublicUrl(raw, { + fetcher = globalThis.fetch, + lookup = dnsLookup, + maxRedirects = 3, + timeoutMs = 10_000, +} = {}) { + let current = raw; + for (let hop = 0; hop <= maxRedirects; hop += 1) { + const destination = await resolvePublicUrl(current, lookup); + if (!destination) return { ok: false, kind: 'blocked' }; + const { url, addresses } = destination; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + let response; + try { + response = fetcher === globalThis.fetch + ? await pinnedHttpsRequest(url, addresses, controller.signal) + : await fetcher(url.toString(), { + headers: { Accept: 'text/markdown, text/plain;q=0.9' }, + redirect: 'manual', + signal: controller.signal, + }); + } catch { + return { ok: false, kind: 'transport' }; + } finally { + clearTimeout(timeout); + } + + if (response.status < 300 || response.status >= 400) return { ok: true, response }; + const location = response.headers.get('location'); + void response.body?.cancel().catch(() => {}); + if (!location) return { ok: true, response }; + current = new URL(location, url).toString(); + } + return { ok: false, kind: 'blocked' }; +} + +export async function readBoundedText(response, maxBytes = 256 * 1024, timeoutMs = 12_000) { + const length = Number(response.headers.get('content-length')); + if (Number.isFinite(length) && length > maxBytes) { + void response.body?.cancel().catch(() => {}); + return { ok: false, reason: 'too-large' }; + } + const reader = response.body?.getReader(); + if (!reader) return { ok: false, reason: 'unreadable' }; + const chunks = []; + let size = 0; + let timer; + try { + const reading = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) return { ok: false, reason: 'too-large' }; + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { ok: true, text: new TextDecoder().decode(bytes) }; + }; + const expired = new Promise((resolve) => { + timer = setTimeout(() => resolve({ ok: false, reason: 'unreadable' }), timeoutMs); + }); + return await Promise.race([reading(), expired]); + } catch { + return { ok: false, reason: 'unreadable' }; + } finally { + if (timer) clearTimeout(timer); + void reader.cancel().catch(() => {}); + } +} diff --git a/test/normalize.test.js b/test/normalize.test.js index 7e56501..98f9394 100644 --- a/test/normalize.test.js +++ b/test/normalize.test.js @@ -14,6 +14,7 @@ import { parseProfile, ProfileValidationError, missingFields, + PROFILE_ENHANCE_SCRIPT, renderProfileFields, toCsv, } from "../src/index.js"; @@ -163,6 +164,14 @@ describe("rendering", () => { assert.ok(!html.includes("