Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,32 @@ export const { GET, PUT, PATCH } = createProfileHandlers({
});
```

On a signed-in profile settings form, enable URL prefill without adding another
API route:

```jsx
<ProfileFields
values={values}
onChange={setValues}
importOpenProfile
/>
```

The importer reads an OpenProfile.md URL through the authenticated
`GET /api/v1/me/profile?openprofile=<url>` handler, then fills only matching
fields. It does not save; the person reviews and submits the existing form.
The server follows only public HTTPS hosts, rechecks redirects, caps the
document at 256 KB, and returns normalized contact fields rather than the
source document. Server-rendered forms can pass `importOpenProfile: true` to
`renderProfileFields` and include `PROFILE_ENHANCE_SCRIPT` on the page.

After an account connection, server code can save the linked profile into
blank fields while preserving anything the person already entered:

```js
await saveOpenProfileContact(profileStore, user.id, coinpayUser.openprofile_url);
```

### Hono on Turso

```js
Expand Down
12 changes: 12 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export interface HandlerOptions {
getUser(request: Request, ...rest: unknown[]): SessionUser | null | Promise<SessionUser | null>;
required?: readonly ProfileField[];
onSaved?(profile: Profile, user: SessionUser): unknown;
openProfileImporter?(url: string, context: { request: Request; user: SessionUser }): Promise<Partial<Record<ProfileField, string>>>;
}
export function createProfileHandlers(options: HandlerOptions): {
GET(request: Request, ...rest: unknown[]): Promise<Response>;
Expand All @@ -141,6 +142,13 @@ export function saveSignupProfile(
input: ProfileInput,
options?: { email?: string | null; required?: readonly ProfileField[] },
): Promise<ValidationResult & { profile: Partial<ProfileFields> | Profile }>;
/** Fetch an OpenProfile URL and persist its fields only where the local profile is blank. */
export function saveOpenProfileContact(
store: ProfileStore,
userId: string | number,
url: string,
fetchOptions?: { fetcher?: typeof fetch; lookup?: unknown },
): Promise<Profile>;

export interface RenderOptions {
values?: Partial<Record<ProfileField, string | null>>;
Expand All @@ -150,6 +158,10 @@ export interface RenderOptions {
idPrefix?: string;
className?: string;
labels?: Partial<Record<ProfileField, string>>;
/** Show a URL field that pre-fills these fields from OpenProfile.md. */
importOpenProfile?: boolean;
/** Same-origin authenticated profile endpoint used by the importer. */
importEndpoint?: string;
}
export function renderProfileFields(options?: RenderOptions): string;
export const PROFILE_ENHANCE_SCRIPT: string;
Expand Down
16 changes: 14 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/profile",
"version": "0.1.6",
"version": "0.1.7",
"type": "module",
"description": "One user profile for every property: email, first_name, last_name, company_domain, job_title, linkedin_url. Validation, signup form fields, storage on Postgres/Turso/Supabase, HTTP handlers, CSV export, CLI and MCP.",
"keywords": [
Expand Down Expand Up @@ -72,6 +72,9 @@
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"dependencies": {
"@profullstack/openprofile": "^0.2.0"
},
"engines": {
"node": ">=20"
}
Expand Down
2 changes: 2 additions & 0 deletions react.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface ProfileFieldsProps extends RenderOptions {
/** Initial values when uncontrolled. */
defaultValues?: Partial<Record<ProfileField, string | null>>;
disabled?: boolean;
/** Override the default same-origin profile request when the app uses custom auth. */
importOpenProfileFetcher?(url: string): Promise<Partial<Record<ProfileField, string>>>;
}

export function ProfileFields(props: ProfileFieldsProps): ReactElement;
28 changes: 26 additions & 2 deletions src/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ export async function saveSignupProfile(store, userId, input, { email, required
return { ...result, profile: await store.save(userId, result.profile) };
}

/** Fetch an OpenProfile and fill only missing stored contact fields. */
export async function saveOpenProfileContact(store, userId, url, fetchOptions) {
if (!store || typeof store.fillBlanks !== "function") throw new Error("profile: a profile store is required");
if (userId === null || userId === undefined || userId === "") throw new Error("profile: userId is required");
const { fetchOpenProfileContact } = await import("./openprofile-import.js");
const imported = await fetchOpenProfileContact(url, fetchOptions);
return store.fillBlanks(userId, imported);
}

function withEmail(input, email) {
if (!email) return input;
const has = input && (typeof input.get === "function" ? input.get("email") : input.email);
Expand All @@ -51,7 +60,7 @@ function withEmail(input, email) {
* The account email fills the profile email when the profile has none.
* `required` applies to PUT (a whole profile); PATCH checks only what it sends.
*/
export function createProfileHandlers({ store, getUser, required = DEFAULT_REQUIRED, onSaved } = {}) {
export function createProfileHandlers({ store, getUser, required = DEFAULT_REQUIRED, onSaved, openProfileImporter } = {}) {
if (!store || typeof getUser !== "function") throw new Error("profile: store and getUser are required");

const authed = (fn) => async (request, ...rest) => {
Expand Down Expand Up @@ -88,7 +97,22 @@ export function createProfileHandlers({ store, getUser, required = DEFAULT_REQUI
});

return {
GET: authed(async (_request, user) => {
GET: authed(async (request, user) => {
const requestedProfile = new URL(request.url).searchParams.get("openprofile");
if (requestedProfile !== null) {
try {
// Keep the DNS-aware fetch implementation out of the client bundle;
// this branch runs only in the authenticated server handler.
const importer = openProfileImporter ?? (async (url) => {
const { fetchOpenProfileContact } = await import("./openprofile-import.js");
return fetchOpenProfileContact(url);
});
return json({ profile: await importer(requestedProfile, { request, user }) });
} catch (error) {
if (error?.name === "OpenProfileImportError") return json({ error: error.message }, error.status);
throw error;
}
}
const profile = (await store.get(user.id)) ?? { user_id: String(user.id), email: user.email ?? null };
if (!profile.email && user.email) profile.email = user.email;
return json({ profile, missing: missingFields(profile, required) });
Expand Down
23 changes: 21 additions & 2 deletions src/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@ export function renderProfileFields({
idPrefix = "profile-",
className = "pfs-profile",
labels = {},
importOpenProfile = false,
importEndpoint = "/api/v1/me/profile",
} = {}) {
const importer = importOpenProfile ? `<div class="${esc(className)}__import" data-pfs-openprofile-import data-endpoint="${esc(importEndpoint)}" style="display:grid;gap:.5rem;margin-bottom:1rem">
<label for="${esc(`${idPrefix}openprofile-url`)}">Prefill from OpenProfile.md</label>
<div style="display:flex;flex-wrap:wrap;gap:.5rem">
<input id="${esc(`${idPrefix}openprofile-url`)}" type="url" autocomplete="url" placeholder="https://example.com/.well-known/openprofile.md" data-pfs-openprofile-url style="flex:1 1 18rem;min-width:0;padding:.5rem .75rem;border:1px solid currentColor;border-radius:.375rem;background:transparent;color:inherit;font:inherit">
<button type="button" data-pfs-openprofile-button disabled style="padding:.5rem .75rem;border:1px solid currentColor;border-radius:.375rem;background:transparent;color:inherit;font:inherit">Fetch and prefill</button>
</div>
<p class="${esc(className)}__import-status" data-pfs-openprofile-status role="status" aria-live="polite" style="min-height:1.25em;margin:0">Fetch to fill the fields, then review or edit them before saving.</p>
</div>` : "";
const rows = fields.map((name) => {
const f = FIELD_BY_NAME[name];
if (!f) throw new Error(`profile: unknown field ${name}`);
Expand All @@ -47,7 +57,7 @@ export function renderProfileFields({
<input ${attrs.join(" ")}>${err ? `\n <p class="${esc(className)}__error" id="${esc(id)}-error" role="alert">${esc(err)}</p>` : ""}
</div>`;
});
return `<fieldset class="${esc(className)}">\n${rows.join("\n")}\n</fieldset>`;
return `<fieldset class="${esc(className)}">\n${importer}${rows.join("\n")}\n</fieldset>`;
}

/**
Expand All @@ -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();}});});})();`;
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
154 changes: 154 additions & 0 deletions src/openprofile-import.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading