Setting the file. One moment.
Fetch People Avatars · PR To Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page scripts/ fetch-people-avatars.mjs
JavaScript · 157 lines · 6 KB
//
15 // Network is constrained on purpose: only https GitHub avatar hosts are fetched
16 // (SSRF guard), and bytes are only ever written under the project dir (no path
17 // traversal), so a tampered people.json can't redirect the fetch or the write.
18 //
19 // Reads:
20 // --people <path> capture/extracted/people.json (from ingest.mjs)
21 // Writes:
22 // assets/<login>.png one per contributor whose avatar resolved
23 // (rewrites people.json in place with avatarFetched: true/false)
24 //
25 // Flags: --project-dir . --timeout 8000 (ms per request)
26 // Avatars are written to <project-dir>/<person.avatarFile>, where avatarFile is
27 // the project-root-relative "assets/<login>.png" — the SAME assets/ dir the frame
28 // workers reference and assemble-index stages (lib/assets.mjs). Anchor on the
29 // project root so the path stays under the project's assets/.
30 //
31 // Usage (orchestrator already cd'd into PROJECT_DIR, so --project-dir defaults to "."):
32 // node fetch-people-avatars.mjs --people ./capture/extracted/people.json
33
34 import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs" ;
35 import { resolve, join, dirname, sep } from "node:path" ;
36
37 const argv = process.argv. slice ( 2 );
38 const flag = ( name , def ) => {
39 const i = argv. indexOf ( `--${ name }` );
40 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
41 };
42
43 const peoplePath = resolve ( flag ( "people" , "./capture/extracted/people.json" ));
44 const projectDir = resolve ( flag ( "project-dir" , "." ));
45 const TIMEOUT = parseInt ( flag ( "timeout" , "8000" ), 10 );
46
47 // SSRF guard: avatars only ever come from GitHub's avatar hosts, so refuse any
48 // other URL rather than fetching whatever string people.json happens to carry.
49 // `github.com/<login>.png` 302s to avatars.githubusercontent.com (redirect stays
50 // on-host, controlled by GitHub).
51 const AVATAR_HOSTS = new Set ([ "avatars.githubusercontent.com" , "github.com" , "www.github.com" ]);
52 function isAllowedAvatarUrl ( u ) {
53 let parsed;
54 try {
55 parsed = new URL (u);
56 } catch {
57 return false ;
58 }
59 if (parsed.protocol !== "https:" ) return false ;
60 const host = parsed.hostname. toLowerCase ();
61 return AVATAR_HOSTS . has (host) || host. endsWith ( ".githubusercontent.com" );
62 }
63
64 // Path guard: the written file must stay inside the project dir, so a crafted
65 // avatarFile ("../../etc/…") can't escape via join().
66 function isUnderProject ( p ) {
67 const r = resolve (p);
68 return r === projectDir || r. startsWith (projectDir + sep);
69 }
70
71 // Soft-exit helper — avatars are optional, so every early-out is exit 0.
72 function softExit ( msg ) {
73 console. log ( `• fetch-avatars: ${ msg }` );
74 process. exit ( 0 );
75 }
76
77 if ( ! existsSync (peoplePath)) softExit ( `no people.json at ${ peoplePath } — skipping (no avatars)` );
78
79 let doc;
80 try {
81 doc = JSON . parse ( readFileSync (peoplePath, "utf8" ));
82 } catch (e) {
83 softExit ( `people.json unreadable (${ e . message }) — skipping` );
84 }
85
86 const people = Array. isArray (doc.people) ? doc.people : [];
87 if ( ! people. length ) softExit ( "no contributors in people.json — skipping" );
88
89 async function fetchOne ( person ) {
90 const { login , avatarUrl } = person;
91 if ( ! login || ! avatarUrl) return "skip" ;
92 if ( ! isAllowedAvatarUrl (avatarUrl)) {
93 person.avatarFetched = false ;
94 console. log ( ` (skip avatar @${ login }: not a GitHub avatar URL)` );
95 return "fail" ;
96 }
97 // avatarFile is project-root-relative ("assets/<login>.png"); anchor on the
98 // project root so it stays under the project's assets/ dir.
99 const dest = join (projectDir, person.avatarFile || `assets/${ login }.png` );
100 if ( ! isUnderProject (dest)) {
101 person.avatarFetched = false ;
102 console. log ( ` (skip avatar @${ login }: avatar path escapes the project dir)` );
103 return "fail" ;
104 }
105 mkdirSync ( dirname (dest), { recursive: true });
106 // Idempotent: a non-empty file from a prior run is reused (re-runs are free).
107 if ( existsSync (dest) && statSync (dest).size > 0 ) {
108 person.avatarFetched = true ;
109 return "cached" ;
110 }
111 const ctrl = new AbortController ();
112 const timer = setTimeout (() => ctrl. abort (), TIMEOUT );
113 try {
114 const res = await fetch (avatarUrl, {
115 signal: ctrl.signal,
116 redirect: "follow" , // github.com/<login>.png redirects to avatars.githubusercontent.com
117 headers: { "User-Agent" : "hyperframes-pr-to-video" },
118 });
119 if ( ! res.ok) throw new Error ( `HTTP ${ res . status }` );
120 const buf = Buffer. from ( await res. arrayBuffer ());
121 if ( ! buf. length ) throw new Error ( "empty body" );
122 writeFileSync (dest, buf);
123 person.avatarFetched = true ;
124 return "ok" ;
125 } catch (e) {
126 person.avatarFetched = false ;
127 console. log ( ` (skip avatar @${ login }: ${ e . message })` );
128 return "fail" ;
129 } finally {
130 clearTimeout (timer);
131 }
132 }
133
134 let ok = 0 ;
135 let cached = 0 ;
136 let fail = 0 ;
137 // Sequential keeps it simple and gentle on github.com; the list is tiny (a PR's
138 // contributors), so latency is not a concern.
139 for ( const person of people) {
140 const r = await fetchOne (person);
141 if (r === "ok" ) ok ++ ;
142 else if (r === "cached" ) cached ++ ;
143 else if (r === "fail" ) fail ++ ;
144 }
145
146 // Persist avatarFetched flags so story-design can reference only real avatars.
147 try {
148 writeFileSync (peoplePath, JSON . stringify (doc, null , 2 ) + " \n " );
149 } catch (e) {
150 console. log ( ` (warn: could not rewrite people.json flags: ${ e . message })` );
151 }
152
153 console. log (
154 `✓ fetch-avatars: ${ ok + cached }/${ people . length } avatar(s) in assets/` +
155 ` (${ ok } new, ${ cached } cached, ${ fail } failed)` ,
156 );
157 process. exit ( 0 );