( discoveredLotties: DiscoveredLottie[], lottieDir: string, )
| 28 | */ |
| 29 | // fallow-ignore-next-line complexity |
| 30 | export async function saveLottieAnimations( |
| 31 | discoveredLotties: DiscoveredLottie[], |
| 32 | lottieDir: string, |
| 33 | ): Promise<number> { |
| 34 | let savedCount = 0; |
| 35 | const savedHashes = new Set<string>(); // Deduplicate by content |
| 36 | |
| 37 | for (let li = 0; li < discoveredLotties.length && li < 10; li++) { |
| 38 | const lottieItem = discoveredLotties[li]!; |
| 39 | try { |
| 40 | let jsonData: string | undefined; |
| 41 | |
| 42 | if (lottieItem.data) { |
| 43 | // Already have the JSON data from network interception |
| 44 | jsonData = JSON.stringify(lottieItem.data); |
| 45 | } else if (lottieItem.url) { |
| 46 | // SSRF guard — safeFetch re-checks the denylist on every redirect hop |
| 47 | const res = await safeFetch(lottieItem.url, { |
| 48 | signal: AbortSignal.timeout(10000), |
| 49 | headers: { "User-Agent": "HyperFrames/1.0" }, |
| 50 | }); |
| 51 | if (!res || !res.ok) continue; |
| 52 | const buf = Buffer.from(await res.arrayBuffer()); |
| 53 | |
| 54 | if (lottieItem.url.endsWith(".lottie")) { |
| 55 | // dotLottie is a ZIP — extract the animation JSON |
| 56 | try { |
| 57 | const AdmZip = (await import("adm-zip")).default; |
| 58 | const zip = new AdmZip(buf); |
| 59 | const entries = zip.getEntries(); |
| 60 | // Look for animation JSON in both v1 (animations/) and v2 (a/) paths |
| 61 | const animEntry = entries.find( |
| 62 | (e) => |
| 63 | (e.entryName.startsWith("a/") || e.entryName.startsWith("animations/")) && |
| 64 | e.entryName.endsWith(".json"), |
| 65 | ); |
| 66 | if (animEntry) { |
| 67 | jsonData = animEntry.getData().toString("utf-8"); |
| 68 | } |
| 69 | } catch { |
| 70 | // adm-zip not available or extraction failed — save raw .lottie |
| 71 | const hash = buf.toString("base64").slice(0, 100); |
| 72 | if (savedHashes.has(hash)) continue; |
| 73 | savedHashes.add(hash); |
| 74 | writeFileSync(join(lottieDir, `animation-${savedCount}.lottie`), buf); |
| 75 | savedCount++; |
| 76 | continue; |
| 77 | } |
| 78 | } else { |
| 79 | // Plain JSON file |
| 80 | jsonData = buf.toString("utf-8"); |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | if (jsonData) { |
| 85 | // Deduplicate by content hash (first 100 chars of stringified JSON) |
| 86 | const hash = jsonData.slice(0, 200); |
| 87 | if (savedHashes.has(hash)) continue; |
no test coverage detected