// 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 `magick` command to resize images, rather // than using linking libMagicCore or libMagic++. // // It is kept small and simple partly because "yay, simplicity!", and // partly for pedagogical perposes. // // [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" "fmt" "io" "log" "net" "net/http" "os" "os/exec" "sort" "strconv" "strings" "sync" "time" sd "git.lukeshu.com/go/libsystemd/sd_daemon" ) // Hard-coded config /////////////////////////////////////////////////////////// const ( UserlistCacheTime = 5 * time.Second AllowableNetSkew = 5 * time.Second DefaultSockName = "tcp::8080" EMailDomain = "umorpha.io" KChatDomain = "umorpha-systems" EnableListing = true ) // 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 ExitNotConfigured = 6 ) 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 } func main() { if err := Main(); 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() { fmt.Printf(`Usage: %s [--help|LISTEN_ADDR] Run a Libravatar origin server that uses Infomaniak kChat as data-source If LISTEN_ADDR isn't specified, then %q is used. LISTEN_ADDR is a "family:address" pair, where the "family" is one of "tcp", "tcp4", "tcp6", "unix", "unixpacket" (the usual Go net.Listen() network names), or "fd" (to listen on an already-open file descriptor). You must also set the environment variable INFOMANIAK_APIKEY. `, os.Args[0], DefaultSockName) } func Main() error { var sockname string switch len(os.Args) { case 0, 1: sockname = DefaultSockName case 2: if os.Args[1] == "--help" { usage() return nil } sockname = os.Args[1] default: return &ErrorWithCode{ Err: fmt.Errorf("expected 0 or 1 arguments, got %d: %q", len(os.Args)-1, os.Args[1:]), Code: ExitInvalidArgument, } } stype, saddr, ok := strings.Cut(sockname, ":") if !ok { return &ErrorWithCode{ Err: fmt.Errorf("invalid address: %q\n", sockname), Code: ExitInvalidArgument, } } apikey := os.Getenv("INFOMANIAK_APIKEY") if apikey == "" { return &ErrorWithCode{ Err: fmt.Errorf("must set the INFOMANIAK_APIKEY environment variable"), Code: ExitNotConfigured, } } // Unset the env-var so it doesn't get passed to ImageMagick. os.Unsetenv("INFOMANIAK_APIKEY") sock, err := netListen(stype, saddr) if err != nil { return err } server := &serverState{ infomaniak: infomaniakConfig{ APIKey: apikey, EMailDomain: EMailDomain, KChatDomain: KChatDomain, }, 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 // 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 !EnableListing { 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, `