1// What a person says about themselves on their page (/<owner>/), which2// they set over ssh (bin/forge profile): a bio line, a link and the repos3// they pin. Kept in .forge/profiles/<user>, which backups carry, a line4// each ("bio <text>", "link <url>", "pin <repo> <repo>"), with "joined5// <unix seconds>", the time their first key was added (forge user add).6//7// node lib/profile.ts <user> print it8// node lib/profile.ts <user> bio <text>|--clear set or clear a field9// node lib/profile.ts <user> link <url>|--clear10// node lib/profile.ts <user> pin <repo>...|--clear11// node lib/profile.ts --joined <user> note now as when they joined, unless noted12import fs from 'node:fs';13import path from 'node:path';14import { fileURLToPath } from 'node:url';1516export const BIO_MAX = 120;17export const LINK_MAX = 200;18export const PINS = 3;1920export interface Profile {21 bio: string;22 link: string;23 pins: string[];24 // Unix seconds, 0 for not known.25 joined: number;26}2728const file = (state: string, user: string): string => path.join(state, 'profiles', user);2930export function readProfile(state: string, user: string): Profile {31 const p: Profile = { bio: '', link: '', pins: [], joined: 0 };32 let text = '';33 try {34 text = fs.readFileSync(file(state, user), 'utf8');35 } catch {36 return p;37 }38 for (const line of text.split('\n')) {39 const sp = line.indexOf(' ');40 const [k, v] = sp < 0 ? [line, ''] : [line.slice(0, sp), line.slice(sp + 1)];41 if (k === 'bio' && check.bio(v).ok) p.bio = v;42 else if (k === 'link' && check.link(v).ok) p.link = v;43 else if (k === 'pin') p.pins = v.split(' ').filter((n) => NAME.test(n)).slice(0, PINS);44 else if (k === 'joined' && /^\d+$/.test(v)) p.joined = Number(v);45 }46 return p;47}4849function writeProfile(state: string, user: string, p: Profile): void {50 const lines = [p.bio && `bio ${p.bio}`, p.link && `link ${p.link}`, p.pins.length && `pin ${p.pins.join(' ')}`, p.joined && `joined ${p.joined}`].filter(Boolean);51 const f = file(state, user);52 fs.mkdirSync(path.dirname(f), { recursive: true });53 fs.writeFileSync(`${f}.tmp`, lines.length ? lines.join('\n') + '\n' : '');54 fs.renameSync(`${f}.tmp`, f);55}5657const NAME = /^[A-Za-z0-9._-]+$/;58const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;5960type Checked = { ok: true; value: string } | { ok: false; error: string };6162export const check = {63 bio(text: string): Checked {64 const s = text.trim();65 const n = [...s].length;66 if (CONTROL.test(s)) return { ok: false, error: 'a bio is one line of plain text, without control characters' };67 if (!n) return { ok: false, error: 'an empty bio; profile bio --clear removes it' };68 if (n > BIO_MAX) return { ok: false, error: `a bio of ${n} characters; it can have ${BIO_MAX} at most` };69 return { ok: true, value: s };70 },71 link(text: string): Checked {72 const s = text.trim();73 if (!s) return { ok: false, error: 'no link; profile link --clear removes it' };74 if (CONTROL.test(s) || /\s/.test(s)) return { ok: false, error: 'a link is one https:// address, without spaces' };75 if (s.length > LINK_MAX) return { ok: false, error: `a link of ${s.length} characters; it can have ${LINK_MAX} at most` };76 let u: URL;77 try {78 u = new URL(s);79 } catch {80 return { ok: false, error: `${s} isn't an address; want https://...` };81 }82 if (u.protocol !== 'https:' || !/^https:\/\//i.test(s)) return { ok: false, error: 'only https:// links' };83 if (u.username || u.password) return { ok: false, error: 'a link without a user name or password in it' };84 if (!u.hostname.includes('.') || u.hostname.endsWith('.')) return { ok: false, error: `${u.hostname} isn't a host name the web can reach` };85 return { ok: true, value: u.href };86 },87};8889// A link as the page shows it: no scheme, no lone trailing slash.90export const linkText = (href: string): string => href.replace(/^https:\/\//, '').replace(/^([^/?#]+)\/$/, '$1');9192// Check pins against the user's repos: each one theirs, public, once.93function checkPins(root: string, user: string, names: string[]): { ok: true; value: string[] } | { ok: false; error: string } {94 if (names.length > PINS) return { ok: false, error: `${names.length} repos; pin ${PINS} at most` };95 const out: string[] = [];96 for (const raw of names) {97 const [o, n] = raw.includes('/') ? raw.split('/', 2) : [user, raw];98 const name = n.replace(/\.git$/, '');99 if (o !== user) return { ok: false, error: `${raw} isn't yours; pin your own repos` };100 if (!NAME.test(name) || name.startsWith('.')) return { ok: false, error: `${raw} isn't a repo name` };101 const dir = path.join(root, user, `${name}.git`);102 if (!fs.existsSync(path.join(dir, 'HEAD'))) return { ok: false, error: `you have no repo ${name}` };103 if (!fs.existsSync(path.join(dir, 'git-daemon-export-ok'))) return { ok: false, error: `${name} is private; only public repos are pinned` };104 if (out.includes(name)) return { ok: false, error: `${name} is there twice` };105 out.push(name);106 }107 return { ok: true, value: out };108}109110const FIELDS = 'profile [bio <text>|link <url>|pin <repo>...] (--clear clears one)';111112// One profile command; what it prints, or an error. changed: write it, and113// rebuild the page.114export function command(root: string, state: string, user: string, args: string[]): { out: string[]; note: string; changed: boolean } | { error: string } {115 const p = readProfile(state, user);116 if (!args.length) {117 return {118 out: [119 `bio: ${p.bio || '(none)'}`,120 `link: ${p.link || '(none)'}`,121 `pin: ${p.pins.length ? p.pins.join(' ') : `(none: your page shows your ${PINS} most recently active repos)`}`,122 ],123 note: '',124 changed: false,125 };126 }127 const [field, ...rest] = args;128 const clear = rest.length === 1 && rest[0] === '--clear';129 if (field !== 'bio' && field !== 'link' && field !== 'pin') return { error: `usage: ${FIELDS}` };130 if (!rest.length || (rest.includes('--clear') && !clear)) return { error: `usage: profile ${field} ${field === 'bio' ? '<text>' : field === 'link' ? '<url>' : '<repo> [<repo> <repo>]'} | profile ${field} --clear` };131 let note: string;132 if (field === 'bio') {133 if (clear) [p.bio, note] = ['', 'your page has no bio now'];134 else {135 const r = check.bio(rest.join(' '));136 if (!r.ok) return { error: r.error };137 [p.bio, note] = [r.value, `your page's bio: ${r.value}`];138 }139 } else if (field === 'link') {140 if (clear) [p.link, note] = ['', 'your page has no link now'];141 else {142 if (rest.length > 1) return { error: 'one link, without spaces' };143 const r = check.link(rest[0]);144 if (!r.ok) return { error: r.error };145 [p.link, note] = [r.value, `your page links to ${linkText(r.value)}`];146 }147 } else if (clear) [p.pins, note] = [[], `your page shows your ${PINS} most recently active repos`];148 else {149 const r = checkPins(root, user, rest);150 if (!r.ok) return { error: r.error };151 [p.pins, note] = [r.value, `your page pins ${r.value.join(', ')}`];152 }153 writeProfile(state, user, p);154 return { out: [], note, changed: true };155}156157if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {158 const root = path.resolve(process.env.FORGE_ROOT || 'demo/root');159 const state = process.env.FORGE_STATE || path.join(root, '.forge');160 const [user, ...args] = process.argv.slice(2);161 if (user === '--joined') {162 const who = args[0];163 if (!who || !NAME.test(who)) process.exit(2);164 const p = readProfile(state, who);165 if (!p.joined) {166 p.joined = Number(process.env.FORGE_NOW) || Math.floor(Date.now() / 1000);167 writeProfile(state, who, p);168 }169 } else if (!user || !NAME.test(user)) {170 console.error('usage: node lib/profile.ts <user> [bio <text>|link <url>|pin <repo>...] | --joined <user>');171 process.exit(2);172 } else {173 const r = command(root, state, user, args);174 if ('error' in r) {175 console.error(`forge: profile: ${r.error}`);176 process.exit(1);177 }178 for (const l of r.out) console.log(l);179 if (r.note) console.error(r.note);180 // 3: changed, so the page is rebuilt (bin/forge cmd_profile).181 if (r.changed) process.exit(3);182 }183}