// Copyright (C) 2023 Umorpha Systems // SPDX-License-Identifier: AGPL-3.0-or-later // Command avatard is a small webserver implementing the [Libravatar // specification] for federated avatars, using the [Infomaniak API] as // the backend. // // It is intentionally small and simple; a single .go file (plus // static assets), and almost only using the standard library (I // couldn't resist using libsystemd/sd_daemon). // // It does call the external ImageMagick `convert` command to resize // images, rather than using libMagicCore or libMagic++. // // It is kept small and simple partly because "yay, simplicity!", and // partly for pedagogical purposes. // // [Libravatar specification: https://wiki.libravatar.org/api/">Libravatar specification // [Infomaniak API]: https://developer.infomaniak.com/docs/api package main import ( "archive/tar" "bytes" "crypto/md5" "crypto/sha256" "embed" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "log" "net" "net/http" "os" "os/exec" "sort" "strconv" "strings" "sync" "time" sd "git.lukeshu.com/go/libsystemd/sd_daemon" ) // Embed other files /////////////////////////////////////////////////////////// // First, some utility functions for working with an [embed.FS]. func readStaticFile(fs embed.FS, filename string) []byte { // No need to bother checking for errors--an embed.FS will // never have an error other than "file does not exist", and // it's a programming error (not a runtime error) to call this // function for a file that doesn't exist. fh, _ := fs.Open(filename) ret, _ := io.ReadAll(fh) _ = fh.Close() return ret } func readStaticDirAsTar(fs embed.FS, tarPrefix string) []byte { entries, _ := fs.ReadDir(".") var names []string for _, entry := range entries { names = append(names, entry.Name()) } sort.Strings(names) var buf bytes.Buffer w := tar.NewWriter(&buf) for _, name := range names { fb := readStaticFile(fs, name) w.WriteHeader(&tar.Header{ Typeflag: tar.TypeReg, Name: tarPrefix + name, Size: int64(len(fb)), Mode: 0644, }) _, _ = w.Write(fb) } _ = w.Close() return buf.Bytes() } // dirSource is an embedded copy of all source files and build // scripts. This serves 2 purposes: // // 1. Embedding the `index.html`, `favicon.ico`, and `404.png` static // assets so that the are built into the binary instead of having // to read them from disk at runtime. // // 2. Giving the server a "download my own source code" feature, in // order to make AGPL license compliance easy. // //go:embed COPYING.txt Makefile go.mod go.sum avatard.go index.html favicon.ico 404.png var dirSource embed.FS // Go ahead and get a few individual files out of [dirSource]. We // could avoid the need do this (and the need to have a // [readStaticFile] function at all) by instead writing things like: // // //go:embed index.html // var fileIndexHTML []byte // // But that'd mean that the binary contains 2 copies of `index.html`; // the one in dirSource and the one in fileIndexHTML; using // readStaticFile lets us keep it down to one copy. var ( // Individual files. fileIndexHTML = readStaticFile(dirSource, "index.html") fileFaviconICO = readStaticFile(dirSource, "favicon.ico") fileMissingAvatar = readStaticFile(dirSource, "404.png") // fileSourceTar is a tarball of `dirSource`. fileSourceTar = readStaticDirAsTar(dirSource, "avatard/") ) // Main entrypoint ///////////////////////////////////////////////////////////// // Standard exit codes defined by [LSB]. // // [LSB]: http://refspecs.linuxbase.org/LSB_5.0.0/LSB-Core-generic/LSB-Core-generic/iniscrptact.html const ( ExitFailure = 1 ExitInvalidArgument = 2 ) type ErrorWithCode struct { Err error Code int } // Error implements the [error] interface. func (e *ErrorWithCode) Error() string { return e.Err.Error() } // Unwrap implements the interface for [errors.Unwrap]. func (e *ErrorWithCode) Unwrap() error { return e.Err } // main is the function that is called by Go itself when the program // starts. func main() { if err := AvatarDMain(os.Args[1:]); err != nil { fmt.Fprintf(os.Stderr, "%s: error: %v\n", os.Args[0], err) var ecode *ErrorWithCode if errors.As(err, &ecode) { if ecode.Code == ExitInvalidArgument { fmt.Fprintf(os.Stderr, "Try '%s --help' for more information.\n", os.Args[0]) } os.Exit(ecode.Code) } os.Exit(1) } } func usage(argparser *flag.FlagSet) { fmt.Printf(`Usage: %s [OPTIONS] avatar daemon - run a Libravatar origin server that uses Infomaniak kChat as data-source OPTIONS: `, argparser.Name()) argparser.SetOutput(os.Stdout) argparser.PrintDefaults() } // AvatarDMain is the "main" function, but unlike the actual [main] // function, it returns an error; this is a useful thing for 2 reasons: // // 1. Convenience/consistency: When you encounter an error in Go, // most of the time the correct thing to do is to return it. If // you're writing code in a function that doesn't return an error, // then you can't do that, and it means you have to start doing // things that 90% of the time are bad practices. // // 2. I haven't written tests for avatard, but if I did: When writing // tests, it is often useful to have an exported "main" function // that does not call [os.Exit]. func AvatarDMain(argStrs []string) error { argparser := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) argHelp := argparser.Bool("help", false, "show this help text") argSocket := argparser.String("socket", "tcp::8080", "the socket to listen on; a \"family:address\" pair, where the\n"+ "\"family\" is one of \"tcp\", \"tcp4\", \"tcp6\", \"unix\", \"unixpacket\"\n"+ "(the usual Go net.Listen() network names), or \"fd\" (to listen\n"+ "on an already-open file descriptor). The \"address\" part for\n"+ "the \"fd\" family is one of\n"+ " 1. a file descriptor number (or the special names \"stdin\",\n"+ " \"stdout\", or \"stderr\" to refer to 0, 1, or 2\n"+ " respectively),\n"+ " 2. \"systemd:N\" to refer to the Nth file descriptor passed in\n"+ " via systemd (or \"systemd\" as shorthand for \"systemd:0\")\n"+ " (see sd_listend_fds(3)),\n"+ " 3. \"systemd:NAME\" to refer to a named file descriptor passed\n"+ " in via systemd (see sd_listend_fds_with_names(3)).\n") argAPIKeyFile := argparser.String("apikey-file", "", "the path to a file containing the Infomaniak API key to use (required)") argEMailDomain := argparser.String("domain-email", "umorpha.io", "the stuff to the right of '@' in email addresses") argKChatDomain := argparser.String("domain-kchat", "umorpha-systems", "the left-most domain segment in the kChat URL:\n"+ "https://${DOMAIN}.kchat.infomaniak.com/") argEnableListing := argparser.Bool("enable-listing", true, "whether to enable or disable a public list of users") argUserlistCacheTime := argparser.Duration("userlist-cache-time", 5*time.Second, "how long to cache the userlist for") argAllowableNetSkew := argparser.Duration("allowable-net-skew", 5*time.Second, "the maximum expected clock difference between the Infomaniak\n"+ "server and this server") argparser.SetOutput(io.Discard) err := argparser.Parse(argStrs) if *argHelp { usage(argparser) return nil } if err != nil { return &ErrorWithCode{ Err: err, Code: ExitInvalidArgument, } } if argparser.NArg() > 0 { return &ErrorWithCode{ Err: fmt.Errorf("unexpected positional arguments: %q", argparser.Args()), Code: ExitInvalidArgument, } } stype, saddr, ok := strings.Cut(*argSocket, ":") if !ok { return &ErrorWithCode{ Err: fmt.Errorf("invalid address: %q", *argSocket), Code: ExitInvalidArgument, } } if *argAPIKeyFile == "" { return &ErrorWithCode{ Err: fmt.Errorf("the -apikey-file= flag is required"), Code: ExitInvalidArgument, } } apikey, err := os.ReadFile(*argAPIKeyFile) if err != nil { return &ErrorWithCode{ Err: fmt.Errorf("could not read -apikey-file=%q: %w", *argAPIKeyFile, err), Code: ExitInvalidArgument, } } sock, err := netListen(stype, saddr) if err != nil { return err } server := &serverState{ infomaniak: infomaniakConfig{ APIKey: string(apikey), EMailDomain: *argEMailDomain, KChatDomain: *argKChatDomain, }, cfgEnableListing: *argEnableListing, cfgUserlistCacheTime: *argUserlistCacheTime, cfgAllowableNetSkew: *argAllowableNetSkew, cacheRawAvatars: make(map[string]*cachedAvatar), } router := http.NewServeMux() router.HandleFunc("/", serveStatic) router.Handle("/avatar/", server) log.Printf("Serving on %v...", sock.Addr()) return http.Serve(sock, router) } // netListen is like [net.Listen], but with support for our special // "fd" address family. // // The purpose of doing this is that for development I really want to // be able to have it listen on a TCP port, but for actual deployment // I want it to be able to accept an already-opened socket from // systemd, and so I wrote a reasonably genereral solution. // // Did I avoid unnecessary complexity writing special-purpose code, or // did I introduce it by failing to remember that YAGNI? func netListen(stype, saddr string) (net.Listener, error) { switch stype { case "fd": switch saddr { case "stdin", "0": return net.FileListener(os.Stdin) case "stdout", "1": return net.FileListener(os.Stdout) case "stderr", "2": return net.FileListener(os.Stderr) case "systemd": sdFds := sd.ListenFds(true) if len(sdFds) == 0 { return nil, fmt.Errorf("fd:systemd given, but no systemd file descriptors passed in") } return net.FileListener(sdFds[0]) default: if fd, _ := strconv.Atoi(saddr); fd > 0 { return net.FileListener(os.NewFile(uintptr(fd), fmt.Sprintf("/dev/fd/%d", fd))) } if name, ok := strings.CutPrefix(saddr, "systemd:"); ok { sdFiles := sd.ListenFds(true) for _, file := range sdFiles { if file.Name() == name { return net.FileListener(file) } } if n, err := strconv.Atoi(name); err == nil && n >= 0 && n < len(sdFiles) { return net.FileListener(sdFiles[n]) } return nil, fmt.Errorf("does not match any systemd file descriptor: %q", name) } return nil, fmt.Errorf("invalid file descriptor name: %q", saddr) } default: return net.Listen(stype, saddr) } } func serveStatic(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed) return } switch r.URL.Path { case "/": w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(fileIndexHTML) case "/favicon.ico": w.Header().Set("Content-Type", "image/vnd.microsoft.icon") _, _ = w.Write(fileFaviconICO) case "/avatard.tar": w.Header().Set("Content-Type", "image/application/x-tar") _, _ = w.Write(fileSourceTar) default: http.NotFound(w, r) return } } // Infomaniak API client /////////////////////////////////////////////////////// type infomaniakConfig struct { // Static-after-initialization APIKey string EMailDomain string KChatDomain string } // kChatUser is a subset of what the Infomaniak kChat API returns from // `/api/v4/users`. type kChatUser struct { ID string `json:"id"` // a UUID LastPictureUpdate int64 `json:"last_picture_update"` // milliseconds since the Unix epoch EMail string `json:"email"` } // fetchUsers returns a listing of the users on the kChat instance, // keyed by their Libravatar hashes. func (cfg infomaniakConfig) fetchUsers() (map[string]*kChatUser, error) { req, _ := http.NewRequest(http.MethodGet, "https://"+cfg.KChatDomain+".kchat.infomaniak.com/api/v4/users", nil) req.Header.Set("Authorization", "Bearer "+cfg.APIKey) log.Printf("fetch: %s %s", req.Method, req.URL) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } respBody, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %s: %s", resp.Status, respBody) } var users []kChatUser if err := json.Unmarshal(respBody, &users); err != nil { return nil, err } hash2user := make(map[string]*kChatUser, len(users)*2) for i := range users { lowerEMail := strings.ToLower(users[i].EMail) if !strings.HasSuffix(lowerEMail, "@"+cfg.EMailDomain) { continue } hashMD5 := md5.Sum([]byte(lowerEMail)) hash2user[hex.EncodeToString(hashMD5[:])] = &users[i] hashSHA256 := sha256.Sum256([]byte(lowerEMail)) hash2user[hex.EncodeToString(hashSHA256[:])] = &users[i] } return hash2user, nil } // fetchUsers returns the raw bytes of a user's avatar image // (presumably PNG?). func (cfg infomaniakConfig) fetchAvatar(userID string) ([]byte, error) { req, _ := http.NewRequest(http.MethodGet, "https://"+cfg.KChatDomain+".kchat.infomaniak.com/api/v4/users/"+userID+"/image", nil) req.Header.Set("Authorization", "Bearer "+cfg.APIKey) log.Printf("fetch: %s %s", req.Method, req.URL) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) } // Main server code //////////////////////////////////////////////////////////// type serverState struct { // Immutable state infomaniak infomaniakConfig cfgUserlistCacheTime time.Duration cfgAllowableNetSkew time.Duration cfgEnableListing bool // Mutable state cacheUsersMu sync.Mutex cacheUsersUntil time.Time cacheUsers map[string]*kChatUser // keyed by Libravatar hash cacheRawAvatarsMu sync.Mutex cacheRawAvatars map[string]*cachedAvatar // keyed by kChat user ID } // ServeHTTP implements the [http.Handler] interface. func (o *serverState) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed) return } hash := strings.TrimPrefix(r.URL.Path, "/avatar/") if hash == "" { if !o.cfgEnableListing { http.Error(w, "HTTP 403: listing users is forbidden", http.StatusForbidden) return } users, err := o.getUsers() if users == nil { http.Error(w, "HTTP 500: "+err.Error(), http.StatusInternalServerError) return } email2hashes := make(map[string][]string, len(users)/2) for hash, user := range users { email2hashes[user.EMail] = append(email2hashes[user.EMail], hash) } emails := make([]string, 0, len(email2hashes)) for email := range email2hashes { emails = append(emails, email) } sort.Strings(emails) w.Header().Set("Content-Type", "text/html; charset=utf-8") io.WriteString(w, ` avatars `) return } options := r.URL.Query() sizeStr := options.Get("size") if sizeStr == "" { sizeStr = options.Get("s") } size, _ := strconv.ParseUint(sizeStr, 10, 16) if size < 1 || size > 512 { size = 80 } rawAvatar, err := o.getRawAvatar(hash) status := http.StatusOK if rawAvatar == nil { if err != nil { http.Error(w, "HTTP 500: "+err.Error(), http.StatusInternalServerError) return } rawAvatar = fileMissingAvatar status = http.StatusNotFound } // Convert the rawAvatar to PNG and resize it as appropriate. // // TODO(lukeshu): We don't cache resized variants, but we maybe should. cmd := exec.Command("convert", "-", "-resize", fmt.Sprintf("%[1]dx%[1]dx", size), "PNG:-") cmd.Stdin = bytes.NewReader(rawAvatar) resizedAvatar, err := cmd.Output() if err != nil { http.Error(w, "HTTP 500: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/png") w.WriteHeader(status) w.Write(resizedAvatar) } // getUsers returns the list of users, keyed by Libravatar hash. // getUsers does appropriate caching. The returned map will never be // mutated, and the caller should not mutate it. func (o *serverState) getUsers() (map[string]*kChatUser, error) { o.cacheUsersMu.Lock() defer o.cacheUsersMu.Unlock() if time.Now().Before(o.cacheUsersUntil) { return o.cacheUsers, nil } users, err := o.infomaniak.fetchUsers() if err != nil { err = fmt.Errorf("error fetching user list: %w", err) log.Println(err) return o.cacheUsers, err } o.cacheUsers = users o.cacheUsersUntil = time.Now().Add(o.cfgUserlistCacheTime) return users, nil } type cachedAvatar struct { Mu sync.Mutex FetchedAt time.Time Content []byte } // getRawAvatar returns the avatar for a user Libravatar hash, as returned by Infomaniak (so, presumably a 220x220 PNG?) func (o *serverState) getRawAvatar(hash string) ([]byte, error) { users, err := o.getUsers() if users == nil { return nil, err } user := users[hash] if user == nil { return nil, nil } o.cacheRawAvatarsMu.Lock() cur := o.cacheRawAvatars[user.ID] if cur == nil { cur = &cachedAvatar{} cur.Mu.Lock() o.cacheRawAvatars[user.ID] = cur o.cacheRawAvatarsMu.Unlock() } else { o.cacheRawAvatarsMu.Unlock() cur.Mu.Lock() } defer cur.Mu.Unlock() if cur.FetchedAt.Before(time.UnixMilli(user.LastPictureUpdate).Add(o.cfgAllowableNetSkew)) { content, err := o.infomaniak.fetchAvatar(user.ID) if err != nil { err = fmt.Errorf("error fetching avatar: %s: %v", user.ID, err) log.Println(err) return cur.Content, err } cur.Content = content cur.FetchedAt = time.Now() } return cur.Content, nil }