This repository has been archived on 2026-10-18. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
OpenMFA/scripts/fetch-icons.py
T
Miles WardandClaude Fable 5.1 04a86ead95 v0.2: covered codes, service marks, and an audit log
Codes are covered until revealed. A reveal is a server-side, per-session
window (60 s, two periods): POST /api/accounts/{id}/reveal opens it and
returns the code, GET /api/codes only ever returns codes for accounts in
that window, and /hide closes it early. The browser therefore never holds
a code it has not asked for by name, which is what makes the log honest.

Settings -> Log is an audit trail in the style of Daito: one line per
event with who, how long ago and from which address. It records what the
server observed - sign-ins and failed attempts (under the name tried),
reveals, accounts added / renamed / removed, delivery and credential
changes, test messages. Entries are JSON lines in audit.jsonl (owner-only,
newest 5,000 kept, compacted at twice that). The address comes from
X-Forwarded-For / X-Real-IP when parseable, else the TCP peer via
ConnectInfo; in Docker bridge networking that peer is the gateway, which
the README and template now say.

The account list shows each service's mark instead of an initial. The
pack (crates/webui/src/icons.js, generated by scripts/fetch-icons.py) is
~175 marks from Simple Icons (CC0) plus Font Awesome Free brands (CC BY
4.0) for Amazon, AWS, Microsoft, LinkedIn, Slack, Xbox, Salesforce and
Yahoo, which Simple Icons no longer carries. Matching is by issuer name,
then shorter word prefixes, then the label's mail domain; unmatched
accounts keep the initial tile. Marks that would vanish against a theme's
tile fall back to ink for that theme only.

The setup and Delivery platform pickers are plain text; the coloured dots
are gone.

Router tests cover the reveal window, session scoping, 401s, and the
forwarded address landing in the log.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-09 02:08:47 -04:00

239 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Regenerate crates/webui/src/icons.js — the bundled service-icon pack.
The web UI is fully offline, so the icons are fetched here, once, and
committed as a generated JavaScript file that the binary embeds. Two sources:
* Simple Icons (CC0-1.0) — the bulk of the pack, with brand colours
* Font Awesome Free brands (CC BY 4.0) — a handful of brands that Simple
Icons no longer carries (Amazon, AWS, Microsoft, LinkedIn, Slack, ...)
Brands remain trademarks of their owners; the pack exists so the account list
can show a recognisable mark instead of an initial. Run with no arguments:
python3 scripts/fetch-icons.py
"""
import json
import re
import sys
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
SI_VERSION = "16.30.0"
FA_VERSION = "6.7.2"
SI_CDN = f"https://cdn.jsdelivr.net/npm/simple-icons@{SI_VERSION}"
FA_CDN = f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@{FA_VERSION}/svgs/brands"
OUT = Path(__file__).resolve().parent.parent / "crates/webui/src/icons.js"
# Simple Icons slugs. Roughly the services people actually keep in an
# authenticator, weighted towards the homelab / developer crowd.
SIMPLE = """
github google apple facebook meta instagram x discord telegram dropbox box
cloudflare digitalocean netlify vercel gitlab bitbucket atlassian jira trello
notion evernote zoom twitch reddit pinterest snapchat tiktok steam epicgames
playstation ubisoft ea battledotnet riotgames paypal stripe coinbase binance
robinhood venmo wise revolut shopify wordpress squarespace wix godaddy
namecheap porkbun mailchimp hubspot zendesk okta auth0 1password bitwarden
lastpass dashlane keeper nordvpn protonmail proton protonvpn tuta zoho mega
backblaze synology unraid plex nextcloud homeassistant tailscale wireguard
anthropic claude huggingface docker npm pypi rubygems kubernetes akamai vultr
hetzner ovh figma spotify netflix uber airbnb ebay etsy patreon kickstarter
substack medium tumblr mastodon bluesky threads whatsapp signal wikipedia
ubiquiti nvidia intel amd samsung sony cisco mailgun icloud gmail yubico
duolingo coursera udemy ghost hostinger render railway flydotio supabase
firebase mongodb redis elastic datadog newrelic sentry grafana pagerduty
zapier ifttt airtable asana clickup basecamp linear todoist miro dribbble
behance vimeo youtube cashapp americanexpress kucoin trezor opensea
humblebundle rockstargames squareenix roblox gogdotcom googledrive
googlecloud googleplay intuit quickbooks xero square chase bankofamerica
wellsfargo
""".split()
# Font Awesome brand icons filling Simple Icons' gaps: (fa name, title, colour).
FONT_AWESOME = [
("amazon", "Amazon", "#FF9900"),
("aws", "AWS", "#FF9900"),
("microsoft", "Microsoft", "#737373"),
("linkedin", "LinkedIn", "#0A66C2"),
("slack", "Slack", "#4A154B"),
("xbox", "Xbox", "#107C10"),
("salesforce", "Salesforce", "#00A1E0"),
("yahoo", "Yahoo", "#6001D2"),
]
# Extra ways people spell an issuer → slug. Keys are matched after
# normalisation (lower-case, non-alphanumerics dropped), so "Battle.net",
# "battle net" and "BattleNet" all collapse to "battlenet".
ALIASES = {
# Google
"gmail": "gmail", "googleworkspace": "google", "googleaccount": "google",
"gsuite": "google", "googlecloudplatform": "googlecloud", "gcp": "googlecloud",
# Microsoft
"microsoft365": "microsoft", "office365": "microsoft", "office": "microsoft",
"outlook": "microsoft", "outlookcom": "microsoft", "hotmail": "microsoft",
"hotmailcom": "microsoft", "live": "microsoft", "livecom": "microsoft",
"azure": "microsoft", "microsoftazure": "microsoft", "entra": "microsoft",
"onedrive": "microsoft", "xboxlive": "xbox", "microsoftaccount": "microsoft",
# Amazon
"amazonwebservices": "aws", "amazoncom": "amazon", "amazonaws": "aws",
"awsiam": "aws", "amazonprime": "amazon", "primevideo": "amazon",
# Apple
"appleid": "apple", "icloudcom": "icloud", "mecom": "icloud",
# Meta
"fb": "facebook", "ig": "instagram", "twitter": "x", "xcom": "x",
"xtwitter": "x", "twittercom": "x", "whatsappbusiness": "whatsapp",
# Gaming
"blizzard": "battledotnet", "battlenet": "battledotnet", "battle": "battledotnet",
"epic": "epicgames", "epicgamesstore": "epicgames", "riot": "riotgames",
"leagueoflegends": "riotgames", "valorant": "riotgames", "electronicarts": "ea",
"eaaccount": "ea", "origin": "ea", "psn": "playstation", "playstationnetwork": "playstation",
"sonyplaystation": "playstation", "valve": "steam", "steampowered": "steam",
"gog": "gogdotcom", "gogcom": "gogdotcom", "rockstar": "rockstargames",
"socialclub": "rockstargames", "humble": "humblebundle", "ubisoftconnect": "ubisoft",
"uplay": "ubisoft",
# Dev / infra
"githubcom": "github", "gitlabcom": "gitlab", "dockerhub": "docker",
"npmjs": "npm", "npmjscom": "npm", "pypiorg": "pypi", "rubygemsorg": "rubygems",
"k8s": "kubernetes", "do": "digitalocean", "digitalocean": "digitalocean",
"flyio": "flydotio", "fly": "flydotio", "ovhcloud": "ovh", "hetznercloud": "hetzner",
"hetzneronline": "hetzner", "linode": "akamai", "akamailinode": "akamai",
"cloudflareaccess": "cloudflare", "cfaccess": "cloudflare", "huggingfaceco": "huggingface",
"supabasecom": "supabase", "mongodbatlas": "mongodb", "atlas": "mongodb",
"elasticcloud": "elastic", "elasticsearch": "elastic", "grafanacloud": "grafana",
"sentryio": "sentry", "confluence": "atlassian", "atlassianaccount": "atlassian",
"jirasoftware": "jira", "bitbucketorg": "bitbucket", "pagerdutycom": "pagerduty",
"wpcom": "wordpress", "wordpresscom": "wordpress", "wordpressorg": "wordpress",
"namecheapcom": "namecheap", "godaddycom": "godaddy", "porkbuncom": "porkbun",
"netlifycom": "netlify", "vercelcom": "vercel", "renderc": "render", "railwayapp": "railway",
# Homelab
"hass": "homeassistant", "homeassistantio": "homeassistant", "nabucasa": "homeassistant",
"unifi": "ubiquiti", "ui": "ubiquiti", "unificonsole": "ubiquiti", "plextv": "plex",
"synologyaccount": "synology", "synologydsm": "synology", "dsm": "synology",
"nextcloudcom": "nextcloud", "unraidnet": "unraid", "limetech": "unraid",
"tailscalecom": "tailscale",
# Security
"protonme": "proton", "protonmailcom": "protonmail", "protonmail": "protonmail",
"protonpass": "proton", "protondrive": "proton", "tutanota": "tuta", "tutanotacom": "tuta",
"tutaio": "tuta", "1passwordcom": "1password", "onepassword": "1password",
"agilebits": "1password", "bitwardencom": "bitwarden", "vaultwarden": "bitwarden",
"lastpasscom": "lastpass", "keepersecurity": "keeper", "nord": "nordvpn",
"nordaccount": "nordvpn", "yubikey": "yubico", "oktacom": "okta", "auth0com": "auth0",
# Money
"coinbasecom": "coinbase", "coinbasepro": "coinbase", "binancecom": "binance",
"binanceus": "binance", "paypalcom": "paypal", "stripecom": "stripe",
"transferwise": "wise", "wisecom": "wise", "revolutcom": "revolut",
"amex": "americanexpress", "americanexpresscom": "americanexpress",
"jpmorganchase": "chase", "chasecom": "chase", "bofa": "bankofamerica",
"wellsfargocom": "wellsfargo", "cashapp": "cashapp", "squareup": "square",
"quickbooksonline": "quickbooks", "turbotax": "intuit", "mint": "intuit",
# Comms / social
"slackcom": "slack", "discordcom": "discord", "discordapp": "discord",
"zoomus": "zoom", "telegramorg": "telegram", "linkedincom": "linkedin",
"redditcom": "reddit", "twitchtv": "twitch", "tiktokcom": "tiktok", "snap": "snapchat",
"bsky": "bluesky", "blueskysocial": "bluesky", "mastodonsocial": "mastodon",
"signalorg": "signal", "wikimedia": "wikipedia", "wikimediafoundation": "wikipedia",
# Mail hosts (matched from the label's domain when the issuer is unknown)
"yahoocom": "yahoo", "ymail": "yahoo", "ymailcom": "yahoo", "aol": "yahoo",
"zohomail": "zoho", "zohocom": "zoho", "megaio": "mega", "meganz": "mega",
"backblazecom": "backblaze", "b2": "backblaze",
# Misc
"dropboxcom": "dropbox", "boxcom": "box", "notionso": "notion", "evernotecom": "evernote",
"figmacom": "figma", "spotifycom": "spotify", "netflixcom": "netflix",
"ubercom": "uber", "airbnbcom": "airbnb", "ebaycom": "ebay", "etsycom": "etsy",
"patreoncom": "patreon", "substackcom": "substack", "mediumcom": "medium",
"tumblrcom": "tumblr", "shopifycom": "shopify", "squarespacecom": "squarespace",
"wixcom": "wix", "mailchimpcom": "mailchimp", "hubspotcom": "hubspot",
"zendeskcom": "zendesk", "duolingocom": "duolingo", "courseraorg": "coursera",
"udemycom": "udemy", "ghostorg": "ghost", "hostingercom": "hostinger",
"airtablecom": "airtable", "asanacom": "asana", "clickupcom": "clickup",
"basecampcom": "basecamp", "linearapp": "linear", "todoistcom": "todoist",
"mirocom": "miro", "dribbblecom": "dribbble", "behancenet": "behance",
"vimeocom": "vimeo", "youtubecom": "youtube", "yt": "youtube",
"openseaio": "opensea", "trezorio": "trezor", "kucoincom": "kucoin",
"salesforcecom": "salesforce", "sfdc": "salesforce", "nvidiacom": "nvidia",
"geforce": "nvidia", "geforcenow": "nvidia", "samsungaccount": "samsung",
"sonyaccount": "sony", "ciscocom": "cisco", "meraki": "cisco", "intelcom": "intel",
"amdcom": "amd",
"squareenixaccount": "squareenix", "robloxcom": "roblox", "zapiercom": "zapier",
"iftttcom": "ifttt", "vultrcom": "vultr", "datadoghq": "datadog", "newreliccom": "newrelic",
"clickup": "clickup", "anthropiccom": "anthropic", "claudeai": "claude",
}
def norm(s: str) -> str:
return re.sub(r"[^a-z0-9]+", "", s.lower().replace("&", "and"))
def fetch(url: str) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "openmfa-fetch-icons/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8")
def parse_svg(svg: str):
vb = re.search(r'viewBox="([^"]+)"', svg)
paths = re.findall(r'<path[^>]*\sd="([^"]+)"', svg)
if not vb or not paths:
raise ValueError("no viewBox/path")
return vb.group(1), paths
def main() -> int:
index = json.loads(fetch(f"{SI_CDN}/data/simple-icons.json"))
by_slug = {i["slug"]: i for i in (index["icons"] if isinstance(index, dict) else index)}
missing = [s for s in SIMPLE if s not in by_slug]
if missing:
print("not in simple-icons:", " ".join(missing), file=sys.stderr)
return 1
jobs = [(s, "si") for s in SIMPLE] + [(name, "fa") for name, _, _ in FONT_AWESOME]
def load(job):
name, src = job
url = f"{SI_CDN}/icons/{name}.svg" if src == "si" else f"{FA_CDN}/{name}.svg"
return job, parse_svg(fetch(url))
icons = {}
aliases = {}
with ThreadPoolExecutor(max_workers=12) as pool:
for (name, src), (vb, paths) in pool.map(load, jobs):
if src == "si":
meta = by_slug[name]
title, hexcol = meta["title"], "#" + meta["hex"]
for aka in (meta.get("aliases") or {}).get("aka", []):
aliases.setdefault(norm(aka), name)
else:
title, hexcol = next((t, c) for n, t, c in FONT_AWESOME if n == name)
icons[name] = [title, vb, paths, hexcol]
aliases.setdefault(norm(title), name)
for k, v in ALIASES.items():
if v not in icons:
print(f"alias {k!r} points at unknown icon {v!r}", file=sys.stderr)
return 1
aliases[norm(k)] = v
# A key that is itself an icon needs no alias entry.
aliases = {k: v for k, v in sorted(aliases.items()) if k not in icons and k}
body = (
"// GENERATED by scripts/fetch-icons.py — do not edit by hand.\n"
f"// Simple Icons {SI_VERSION} (CC0-1.0) and Font Awesome Free {FA_VERSION} brands\n"
"// (CC BY 4.0). Brand marks are trademarks of their respective owners.\n"
"// Shape: slug -> [title, viewBox, [path d, ...], brand colour].\n"
"window.OPENMFA_ICONS = "
+ json.dumps(dict(sorted(icons.items())), separators=(",", ":"), ensure_ascii=False)
+ ";\n"
"window.OPENMFA_ICON_ALIASES = "
+ json.dumps(aliases, separators=(",", ":"), ensure_ascii=False)
+ ";\n"
)
OUT.write_text(body)
print(f"wrote {OUT} — {len(icons)} icons, {len(aliases)} aliases, {len(body)//1024} KiB")
return 0
if __name__ == "__main__":
sys.exit(main())