avatard/avatard.go

543 lines
14 KiB
Go
Raw Normal View History

2023-12-18 08:08:37 +00:00
// Copyright (C) 2023 Umorpha Systems
// SPDX-License-Identifier: AGPL-3.0-or-later
2023-12-18 16:53:35 +00:00
// 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
2023-12-18 08:08:37 +00:00
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 ///////////////////////////////////////////////////////////
//go:embed COPYING.txt Makefile go.mod go.sum avatard.go index.html favicon.ico 404.png
var dirSource embed.FS
2023-12-18 16:53:35 +00:00
// I could add more `go:embed` comments to embed the []byte arrays
// separately, but that'd bloat the binary size by embedding them
// twice. Instead extract them from `dirSource`.
2023-12-18 08:08:37 +00:00
var fileIndexHTML = func() []byte {
fh, _ := dirSource.Open("index.html")
ret, _ := io.ReadAll(fh)
_ = fh.Close()
return ret
}()
var fileFaviconICO = func() []byte {
fh, _ := dirSource.Open("favicon.ico")
ret, _ := io.ReadAll(fh)
_ = fh.Close()
return ret
}()
var fileMissingAvatar = func() []byte {
fh, _ := dirSource.Open("404.png")
ret, _ := io.ReadAll(fh)
_ = fh.Close()
return ret
}()
2023-12-18 16:53:35 +00:00
// fileSourceTar is a tarball of `dirSource`.
2023-12-18 08:08:37 +00:00
var fileSourceTar = func() []byte {
const tarPrefix = "avatard/"
entries, _ := dirSource.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 {
fh, _ := dirSource.Open(name)
fb, _ := io.ReadAll(fh)
_ = fh.Close()
w.WriteHeader(&tar.Header{
Typeflag: tar.TypeReg,
Name: tarPrefix + name,
Size: int64(len(fb)),
Mode: 0644,
})
_, _ = w.Write(fb)
}
_ = w.Close()
return buf.Bytes()
}()
// 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
}
2023-12-18 16:53:35 +00:00
// Error implements the [error] interface.
2023-12-18 08:08:37 +00:00
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,
}
}
2023-12-18 16:53:35 +00:00
// Unset the env-var so it doesn't get passed to ImageMagick.
2023-12-18 08:08:37 +00:00
os.Unsetenv("INFOMANIAK_APIKEY")
2023-12-18 16:53:35 +00:00
sock, err := netListen(stype, saddr)
2023-12-18 08:08:37 +00:00
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)
2023-12-18 16:53:35 +00:00
log.Printf("Serving on %v...", sock.Addr())
2023-12-18 08:08:37 +00:00
return http.Serve(sock, router)
}
2023-12-18 16:53:35 +00:00
// 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) {
2023-12-18 08:08:37 +00:00
switch stype {
case "fd":
switch saddr {
2023-12-18 16:53:35 +00:00
case "stdin", "0":
2023-12-18 08:08:37 +00:00
return net.FileListener(os.Stdin)
2023-12-18 16:53:35 +00:00
case "stdout", "1":
2023-12-18 08:08:37 +00:00
return net.FileListener(os.Stdout)
2023-12-18 16:53:35 +00:00
case "stderr", "2":
2023-12-18 08:08:37 +00:00
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 {
2023-12-18 16:53:35 +00:00
http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed)
2023-12-18 08:08:37 +00:00
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
}
}
2023-12-18 16:53:35 +00:00
// Infomaniak API client ///////////////////////////////////////////////////////
2023-12-18 08:08:37 +00:00
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)
2023-12-18 16:53:35 +00:00
log.Printf("fetch: %s %s", req.Method, req.URL)
2023-12-18 08:08:37 +00:00
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
}
2023-12-18 16:53:35 +00:00
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %s: %s", resp.Status, respBody)
}
2023-12-18 08:08:37 +00:00
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
}
2023-12-18 16:53:35 +00:00
// fetchUsers returns the raw bytes of a user's avatar image
// (presumably PNG?).
2023-12-18 08:08:37 +00:00
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)
2023-12-18 16:53:35 +00:00
log.Printf("fetch: %s %s", req.Method, req.URL)
2023-12-18 08:08:37 +00:00
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
2023-12-18 16:53:35 +00:00
// Main server code ////////////////////////////////////////////////////////////
2023-12-18 08:08:37 +00:00
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
}
2023-12-18 16:53:35 +00:00
// ServeHTTP implements the [http.Handler] interface.
2023-12-18 08:08:37 +00:00
func (o *serverState) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
2023-12-18 16:53:35 +00:00
http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed)
2023-12-18 08:08:37 +00:00
return
}
hash := strings.TrimPrefix(r.URL.Path, "/avatar/")
if hash == "" {
if !EnableListing {
2023-12-18 16:53:35 +00:00
http.Error(w, "HTTP 403: listing users is forbidden", http.StatusForbidden)
2023-12-18 08:08:37 +00:00
return
}
users, err := o.getUsers()
if users == nil {
2023-12-18 16:53:35 +00:00
http.Error(w, "HTTP 500: "+err.Error(), http.StatusInternalServerError)
2023-12-18 08:08:37 +00:00
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 {
2023-12-18 16:53:35 +00:00
http.Error(w, "HTTP 500: "+err.Error(), http.StatusInternalServerError)
2023-12-18 08:08:37 +00:00
return
}
rawAvatar = fileMissingAvatar
status = http.StatusNotFound
}
2023-12-18 16:53:35 +00:00
// Convert the rawAvatar to PNG and resize it as appropriate.
//
// TODO(lukeshu): We don't cache resized variants, but we maybe should.
2023-12-18 08:08:37 +00:00
cmd := exec.Command("magick", "-", "-resize", fmt.Sprintf("%[1]dx%[1]dx", size), "PNG:-")
cmd.Stdin = bytes.NewReader(rawAvatar)
resizedAvatar, err := cmd.Output()
if err != nil {
2023-12-18 16:53:35 +00:00
http.Error(w, "HTTP 500: "+err.Error(), http.StatusInternalServerError)
2023-12-18 08:08:37 +00:00
return
}
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(status)
w.Write(resizedAvatar)
}
2023-12-18 16:53:35 +00:00
// 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.
2023-12-18 08:08:37 +00:00
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 {
2023-12-18 16:53:35 +00:00
err = fmt.Errorf("error fetching user list: %w", err)
log.Println(err)
2023-12-18 08:08:37 +00:00
return o.cacheUsers, err
}
o.cacheUsers = users
o.cacheUsersUntil = time.Now().Add(UserlistCacheTime)
return users, nil
}
type cachedAvatar struct {
Mu sync.Mutex
FetchedAt time.Time
Content []byte
}
2023-12-18 16:53:35 +00:00
// getRawAvatar returns the avatar for a user Libravatar hash, as returned by Infomaniak (so, presumably a 220x220 PNG?)
2023-12-18 08:08:37 +00:00
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(AllowableNetSkew)) {
content, err := o.infomaniak.fetchAvatar(user.ID)
if err != nil {
2023-12-18 16:53:35 +00:00
err = fmt.Errorf("error fetching avatar: %s: %v", user.ID, err)
log.Println(err)
2023-12-18 08:08:37 +00:00
return cur.Content, err
}
cur.Content = content
cur.FetchedAt = time.Now()
}
return cur.Content, nil
}