avatard/avatard.go

600 lines
18 KiB
Go

// 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, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>avatars</title>
</head>
<body>
<ul>`)
for _, email := range emails {
fmt.Fprintf(w, " <li><tt>%s :", email)
sort.Slice(email2hashes[email], func(i, j int) bool {
return len(email2hashes[email][i]) < len(email2hashes[email][j])
})
for _, hash := range email2hashes[email] {
fmt.Fprintf(w, " <a href=\"%[1]s\">%[1]s</a>\n", hash)
}
io.WriteString(w, "</tt></li>\n")
}
io.WriteString(w, ` </ul>
</body>
</html>
`)
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
}