mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-20 01:49:05 +02:00
199 lines
6.0 KiB
Go
199 lines
6.0 KiB
Go
// Refreshes the AAGUID metadata and authenticator icons from the community AAGUID list
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"maps"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
sourceURL = "https://raw.githubusercontent.com/pocket-id/passkey-aaguids/refs/heads/main/combined_aaguid.json"
|
|
metadataFile = "backend/resources/aaguids.json"
|
|
iconDir = "backend/resources/aaguid-icons"
|
|
svgDataPrefix = "data:image/svg+xml;base64,"
|
|
iconFilePattern = "*.svg"
|
|
iconDigestBytes = 8
|
|
)
|
|
|
|
// authenticator is the subset of an upstream entry this script cares about
|
|
// The icons are data URIs rather than links, so nothing beyond the combined JSON has to be fetched
|
|
type authenticator struct {
|
|
Name string `json:"name"`
|
|
IconLight string `json:"icon_light"`
|
|
IconDark string `json:"icon_dark"`
|
|
}
|
|
|
|
// authenticatorMetadata is the generated runtime representation of an authenticator
|
|
// Icon file names contain a 64-bit SHA-256 prefix of their decoded SVG so identical upstream icons share one asset
|
|
type authenticatorMetadata struct {
|
|
Name string `json:"name"`
|
|
IconLight string `json:"icon_light,omitempty"`
|
|
IconDark string `json:"icon_dark,omitempty"`
|
|
}
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
log.Fatalln("error:", err)
|
|
}
|
|
}
|
|
|
|
// run writes the metadata manifest and its content-addressed icons from one upstream snapshot so their references stay consistent
|
|
func run() error {
|
|
authenticators, err := fetchAuthenticators(sourceURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch %s: %w", sourceURL, err)
|
|
}
|
|
if len(authenticators) == 0 {
|
|
return errors.New("upstream AAGUID list is empty")
|
|
}
|
|
|
|
metadata, icons, err := buildResources(authenticators)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build authenticator resources: %w", err)
|
|
}
|
|
|
|
if err := writeIcons(icons); err != nil {
|
|
return fmt.Errorf("failed to write icon files: %w", err)
|
|
}
|
|
|
|
if err := writeAuthenticatorMetadata(metadata); err != nil {
|
|
return fmt.Errorf("failed to write authenticator metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// fetchAuthenticators downloads the upstream authenticator list keyed by AAGUID
|
|
func fetchAuthenticators(url string) (authenticators map[string]authenticator, err error) {
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get authenticator JSON: %w", err)
|
|
}
|
|
defer func() {
|
|
err = errors.Join(err, resp.Body.Close())
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("expected status 200, got %s", resp.Status)
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&authenticators); err != nil {
|
|
return nil, fmt.Errorf("failed to decode JSON: %w", err)
|
|
}
|
|
|
|
return authenticators, nil
|
|
}
|
|
|
|
// buildResources converts upstream data URIs into metadata references and a set of unique decoded SVGs
|
|
func buildResources(authenticators map[string]authenticator) (map[string]authenticatorMetadata, map[string][]byte, error) {
|
|
metadata := make(map[string]authenticatorMetadata, len(authenticators))
|
|
icons := make(map[string][]byte)
|
|
|
|
// The AAGUIDs are sorted so failures are deterministic when the upstream data contains an invalid icon
|
|
for _, aaguid := range slices.Sorted(maps.Keys(authenticators)) {
|
|
a := authenticators[aaguid]
|
|
entry := authenticatorMetadata{Name: a.Name}
|
|
|
|
// Entries without a usable light SVG retain their display names but do not reference any icon
|
|
lightName, err := addIcon(icons, a.IconLight)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to process light SVG for %s: %w", aaguid, err)
|
|
}
|
|
if lightName == "" {
|
|
metadata[aaguid] = entry
|
|
continue
|
|
}
|
|
entry.IconLight = lightName
|
|
|
|
// A missing or identical dark icon falls back to the light asset at runtime
|
|
darkName, err := addIcon(icons, a.IconDark)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to process dark SVG for %s: %w", aaguid, err)
|
|
}
|
|
if darkName != "" && darkName != lightName {
|
|
entry.IconDark = darkName
|
|
}
|
|
|
|
metadata[aaguid] = entry
|
|
}
|
|
|
|
return metadata, icons, nil
|
|
}
|
|
|
|
// addIcon decodes one SVG data URI and returns the content-addressed file name stored in icons
|
|
func addIcon(icons map[string][]byte, dataURI string) (string, error) {
|
|
payload, ok := strings.CutPrefix(dataURI, svgDataPrefix)
|
|
if !ok {
|
|
return "", nil
|
|
}
|
|
|
|
svg, err := base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decode base64: %w", err)
|
|
}
|
|
|
|
digest := sha256.Sum256(svg)
|
|
name := fmt.Sprintf("%x.svg", digest[:iconDigestBytes])
|
|
if existing, exists := icons[name]; exists && !bytes.Equal(existing, svg) {
|
|
return "", fmt.Errorf("SHA-256 prefix collision for %s", name)
|
|
}
|
|
icons[name] = svg
|
|
|
|
return name, nil
|
|
}
|
|
|
|
// writeIcons replaces the icon directory contents so assets dropped upstream do not linger
|
|
func writeIcons(icons map[string][]byte) error {
|
|
if err := os.MkdirAll(iconDir, 0o755); err != nil {
|
|
return fmt.Errorf("failed to create authenticator icons directory: %w", err)
|
|
}
|
|
|
|
existing, err := filepath.Glob(filepath.Join(iconDir, iconFilePattern))
|
|
if err != nil {
|
|
return fmt.Errorf("error gathering existing SVGs: %w", err)
|
|
}
|
|
for _, path := range existing {
|
|
if err := os.Remove(path); err != nil {
|
|
return fmt.Errorf("error clearing existing SVG: %w", err)
|
|
}
|
|
}
|
|
|
|
for _, name := range slices.Sorted(maps.Keys(icons)) {
|
|
if err := os.WriteFile(filepath.Join(iconDir, name), icons[name], 0o644); err != nil {
|
|
return fmt.Errorf("failed to write SVG %s: %w", name, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// writeAuthenticatorMetadata writes the AAGUID manifest consumed by the backend
|
|
func writeAuthenticatorMetadata(metadata map[string]authenticatorMetadata) (err error) {
|
|
if err := os.MkdirAll(filepath.Dir(metadataFile), 0o755); err != nil {
|
|
return fmt.Errorf("failed to create authenticator metadata directory: %w", err)
|
|
}
|
|
|
|
file, err := os.Create(metadataFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create authenticator metadata file: %w", err)
|
|
}
|
|
defer func() {
|
|
err = errors.Join(err, file.Close())
|
|
}()
|
|
|
|
encoder := json.NewEncoder(file)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(metadata)
|
|
}
|