2024-01-24 06:40:00 +00:00
|
|
|
// Copyright (C) 2023-2024 Umorpha Systems
|
2023-12-18 08:08:37 +00:00
|
|
|
// 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).
|
|
|
|
//
|
2023-12-20 17:18:20 +00:00
|
|
|
// It does call the external ImageMagick `convert` command to resize
|
|
|
|
// images, rather than using libMagicCore or libMagic++.
|
2023-12-18 16:53:35 +00:00
|
|
|
//
|
|
|
|
// It is kept small and simple partly because "yay, simplicity!", and
|
2023-12-20 07:32:37 +00:00
|
|
|
// partly for pedagogical purposes.
|
2023-12-18 16:53:35 +00:00
|
|
|
//
|
|
|
|
// [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"
|
2023-12-20 08:21:48 +00:00
|
|
|
"flag"
|
2023-12-18 08:08:37 +00:00
|
|
|
"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 ///////////////////////////////////////////////////////////
|
|
|
|
|
2023-12-20 07:27:01 +00:00
|
|
|
// 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)
|
2023-12-18 08:08:37 +00:00
|
|
|
ret, _ := io.ReadAll(fh)
|
|
|
|
_ = fh.Close()
|
|
|
|
return ret
|
2023-12-20 07:27:01 +00:00
|
|
|
}
|
2023-12-18 08:08:37 +00:00
|
|
|
|
2023-12-20 07:27:01 +00:00
|
|
|
func readStaticDirAsTar(fs embed.FS, tarPrefix string) []byte {
|
|
|
|
entries, _ := fs.ReadDir(".")
|
2023-12-18 08:08:37 +00:00
|
|
|
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 {
|
2023-12-20 07:27:01 +00:00
|
|
|
fb := readStaticFile(fs, name)
|
2023-12-18 08:08:37 +00:00
|
|
|
w.WriteHeader(&tar.Header{
|
|
|
|
Typeflag: tar.TypeReg,
|
|
|
|
Name: tarPrefix + name,
|
|
|
|
Size: int64(len(fb)),
|
|
|
|
Mode: 0644,
|
|
|
|
})
|
|
|
|
_, _ = w.Write(fb)
|
|
|
|
}
|
|
|
|
_ = w.Close()
|
|
|
|
|
|
|
|
return buf.Bytes()
|
2023-12-20 07:27:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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/")
|
|
|
|
)
|
2023-12-18 08:08:37 +00:00
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
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 }
|
|
|
|
|
2023-12-20 07:32:03 +00:00
|
|
|
// main is the function that is called by Go itself when the program
|
|
|
|
// starts.
|
2023-12-18 08:08:37 +00:00
|
|
|
func main() {
|
2023-12-20 08:21:48 +00:00
|
|
|
if err := AvatarDMain(os.Args[1:]); err != nil {
|
2023-12-18 08:08:37 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-20 08:21:48 +00:00
|
|
|
func usage(argparser *flag.FlagSet) {
|
|
|
|
fmt.Printf(`Usage: %s [OPTIONS]
|
|
|
|
avatar daemon - run a Libravatar origin server that uses Infomaniak kChat as data-source
|
2023-12-18 08:08:37 +00:00
|
|
|
|
2023-12-20 08:21:48 +00:00
|
|
|
OPTIONS:
|
|
|
|
`, argparser.Name())
|
|
|
|
argparser.SetOutput(os.Stdout)
|
|
|
|
argparser.PrintDefaults()
|
2023-12-18 08:08:37 +00:00
|
|
|
}
|
|
|
|
|
2023-12-20 07:32:03 +00:00
|
|
|
// 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].
|
2023-12-20 08:21:48 +00:00
|
|
|
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,
|
2023-12-18 08:08:37 +00:00
|
|
|
}
|
2023-12-20 08:21:48 +00:00
|
|
|
}
|
|
|
|
if argparser.NArg() > 0 {
|
2023-12-18 08:08:37 +00:00
|
|
|
return &ErrorWithCode{
|
2023-12-20 08:21:48 +00:00
|
|
|
Err: fmt.Errorf("unexpected positional arguments: %q", argparser.Args()),
|
2023-12-18 08:08:37 +00:00
|
|
|
Code: ExitInvalidArgument,
|
|
|
|
}
|
|
|
|
}
|
2023-12-20 08:21:48 +00:00
|
|
|
|
|
|
|
stype, saddr, ok := strings.Cut(*argSocket, ":")
|
2023-12-18 08:08:37 +00:00
|
|
|
if !ok {
|
|
|
|
return &ErrorWithCode{
|
2023-12-20 08:21:48 +00:00
|
|
|
Err: fmt.Errorf("invalid address: %q", *argSocket),
|
2023-12-18 08:08:37 +00:00
|
|
|
Code: ExitInvalidArgument,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-20 08:21:48 +00:00
|
|
|
if *argAPIKeyFile == "" {
|
|
|
|
return &ErrorWithCode{
|
|
|
|
Err: fmt.Errorf("the -apikey-file= flag is required"),
|
|
|
|
Code: ExitInvalidArgument,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
apikey, err := os.ReadFile(*argAPIKeyFile)
|
|
|
|
if err != nil {
|
2023-12-18 08:08:37 +00:00
|
|
|
return &ErrorWithCode{
|
2023-12-20 08:21:48 +00:00
|
|
|
Err: fmt.Errorf("could not read -apikey-file=%q: %w", *argAPIKeyFile, err),
|
|
|
|
Code: ExitInvalidArgument,
|
2023-12-18 08:08:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
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{
|
2024-01-24 07:03:48 +00:00
|
|
|
APIKey: strings.TrimSpace(string(apikey)),
|
2023-12-20 08:21:48 +00:00
|
|
|
EMailDomain: *argEMailDomain,
|
|
|
|
KChatDomain: *argKChatDomain,
|
2023-12-18 08:08:37 +00:00
|
|
|
},
|
2023-12-20 08:21:48 +00:00
|
|
|
cfgEnableListing: *argEnableListing,
|
|
|
|
cfgUserlistCacheTime: *argUserlistCacheTime,
|
|
|
|
cfgAllowableNetSkew: *argAllowableNetSkew,
|
|
|
|
|
2023-12-18 08:08:37 +00:00
|
|
|
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":
|
2024-01-24 06:40:00 +00:00
|
|
|
w.Header().Set("Content-Type", "application/x-tar")
|
2023-12-18 08:08:37 +00:00
|
|
|
_, _ = 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
|
2023-12-20 08:21:48 +00:00
|
|
|
infomaniak infomaniakConfig
|
|
|
|
cfgUserlistCacheTime time.Duration
|
|
|
|
cfgAllowableNetSkew time.Duration
|
|
|
|
cfgEnableListing bool
|
2023-12-18 08:08:37 +00:00
|
|
|
|
|
|
|
// 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 == "" {
|
2023-12-20 08:21:48 +00:00
|
|
|
if !o.cfgEnableListing {
|
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-20 17:18:20 +00:00
|
|
|
cmd := exec.Command("convert", "-", "-resize", fmt.Sprintf("%[1]dx%[1]dx", size), "PNG:-")
|
2023-12-18 08:08:37 +00:00
|
|
|
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
|
2023-12-20 08:21:48 +00:00
|
|
|
o.cacheUsersUntil = time.Now().Add(o.cfgUserlistCacheTime)
|
2023-12-18 08:08:37 +00:00
|
|
|
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()
|
|
|
|
|
2023-12-20 08:21:48 +00:00
|
|
|
if cur.FetchedAt.Before(time.UnixMilli(user.LastPictureUpdate).Add(o.cfgAllowableNetSkew)) {
|
2023-12-18 08:08:37 +00:00
|
|
|
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
|
|
|
|
}
|