diff --git a/go.mod b/go.mod index 7885a7c..c4bc5b8 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/sour-is/keyproofs go 1.15 require ( + github.com/disintegration/imaging v1.6.2 github.com/fsnotify/fsnotify v1.4.7 github.com/go-chi/chi v4.1.2+incompatible github.com/google/go-cmp v0.5.4 // indirect github.com/hashicorp/golang-lru v0.5.4 github.com/joho/godotenv v1.3.0 github.com/lucasb-eyer/go-colorful v1.0.3 + github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 github.com/rs/cors v1.7.0 github.com/rs/zerolog v1.20.0 github.com/russross/blackfriday v1.5.2 diff --git a/go.sum b/go.sum index f960eb3..0b67024 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -60,6 +62,8 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 h1:Ys0rDzh8s4UMlGaDa1UTA0sfKgvF0hQZzTYX8ktjiDc= +github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -112,6 +116,8 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9 h1:phUcVbl53swtrUN8kQEXFh golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392 h1:xYJJ3S178yv++9zXV/hnr29plCAGO9vAFG9dorqaFQc= golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/main.go b/main.go index 7656bcc..88e3c58 100644 --- a/main.go +++ b/main.go @@ -89,6 +89,12 @@ func run(ctx context.Context) error { mux := chi.NewRouter() mux.Use( cfg.ApplyHTTP, + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(log.WithContext(r.Context())) + next.ServeHTTP(w, r) + }) + }, secHeaders, cors.New(cors.Options{ AllowCredentials: true, diff --git a/pkg/keyproofs/opengpg.go b/pkg/keyproofs/opengpg.go index a8e815e..5c0cb40 100644 --- a/pkg/keyproofs/opengpg.go +++ b/pkg/keyproofs/opengpg.go @@ -22,12 +22,17 @@ func getOpenPGPkey(ctx context.Context, id string) (entity *Entity, err error) { addr := "https://keys.openpgp.org/vks/v1/by-fingerprint/" + strings.ToUpper(id) return getEntityHTTP(ctx, addr, true) } else if email, err := mail.ParseAddress(id); err == nil { - addr := getWKDPubKeyAddr(email) + addr, advAddr := getWKDPubKeyAddr(email) req, err := getEntityHTTP(ctx, addr, false) if err == nil { return req, err } + req, err = getEntityHTTP(ctx, advAddr, false) + if err == nil { + return req, err + } + addr = "https://keys.openpgp.org/vks/v1/by-email/" + url.QueryEscape(id) return getEntityHTTP(ctx, addr, true) } else { @@ -44,16 +49,15 @@ func getEntityHTTP(ctx context.Context, url string, useArmored bool) (entity *En } cl := http.Client{} resp, err := cl.Do(req) + if err != nil { + return entity, fmt.Errorf("Requesting key: %w\nRemote URL: %v", err, url) + } log.Debug(). Bool("useArmored", useArmored). Str("status", resp.Status). Str("url", url). Msg("getEntityHTTP") - if err != nil { - return entity, fmt.Errorf("Requesting key: %w\nRemote URL: %v", err, url) - } - if resp.StatusCode != 200 { return entity, fmt.Errorf("bad response from remote: %s\nRemote URL: %v", resp.Status, url) } @@ -194,11 +198,11 @@ func isFingerprint(s string) bool { return true } -func getWKDPubKeyAddr(email *mail.Address) string { +func getWKDPubKeyAddr(email *mail.Address) (string, string) { parts := strings.SplitN(email.Address, "@", 2) - hash := sha1.Sum([]byte(parts[0])) lp := zbase32.EncodeToString(hash[:]) - return fmt.Sprintf("https://%s/.well-known/openpgpkey/hu/%s", parts[1], lp) + return fmt.Sprintf("https://%s/.well-known/openpgpkey/hu/%s", parts[1], lp), + fmt.Sprintf("https://openpgpkey.%s/.well-known/openpgpkey/hu/%s/%s", parts[1], parts[1], lp) } diff --git a/pkg/keyproofs/routes-avatar.go b/pkg/keyproofs/routes-avatar.go index 80d07ae..205ffa4 100644 --- a/pkg/keyproofs/routes-avatar.go +++ b/pkg/keyproofs/routes-avatar.go @@ -3,17 +3,23 @@ package keyproofs import ( "context" "crypto/md5" - "crypto/sha1" + "crypto/sha256" + "encoding/base64" "fmt" + "hash" "io" "net/http" "os" "path/filepath" + "strconv" "strings" + "github.com/disintegration/imaging" "github.com/fsnotify/fsnotify" "github.com/go-chi/chi" + "github.com/nullrocks/identicon" "github.com/rs/zerolog/log" + "github.com/sour-is/keyproofs/pkg/graceful" ) @@ -57,14 +63,14 @@ func NewAvatarApp(ctx context.Context, path string) (*avatarApp, error) { path = filepath.Dir(op.Name) kind := filepath.Base(path) name := filepath.Base(op.Name) - if err := createLinks(app.path, kind, name); err != nil { + if err := app.createLinks(kind, name); err != nil { fmt.Println(err) } case fsnotify.Remove, fsnotify.Rename: path = filepath.Dir(op.Name) kind := filepath.Base(path) name := filepath.Base(op.Name) - if err := removeLinks(app.path, kind, name); err != nil { + if err := app.removeLinks(kind, name); err != nil { log.Error().Err(err).Send() } default: @@ -106,7 +112,7 @@ func (app *avatarApp) CheckFiles(ctx context.Context) error { log.Debug().Msgf("link: %s %s %s", app.path, kind, name) - return createLinks(app.path, kind, name) + return app.createLinks(kind, name) }) } @@ -118,13 +124,19 @@ func (app *avatarApp) get(w http.ResponseWriter, r *http.Request) { kind := chi.URLParam(r, "kind") hash := chi.URLParam(r, "hash") + sizeW, sizeH, resize := 0, 0, false + if s, err := strconv.Atoi(r.URL.Query().Get("s")); err == nil && s > 0 { + sizeW, sizeH, resize = sizeByKind(kind, s) + } + log.Debug().Int("width", sizeW).Int("height", sizeH).Bool("resize", resize).Str("kind", kind).Msg("Get Image") + if strings.ContainsRune(hash, '@') { avatarHost, _, err := styleSRV(r.Context(), hash) if err != nil { writeText(w, 500, err.Error()) return } - hash = hashSHA1(strings.ToLower(hash)) + hash = hashSHA256(strings.ToLower(hash)) http.Redirect(w, r, fmt.Sprintf("https://%s/%s/%s?%s", avatarHost, kind, hash, r.URL.RawQuery), 301) return } @@ -132,38 +144,89 @@ func (app *avatarApp) get(w http.ResponseWriter, r *http.Request) { fname := filepath.Join(app.path, ".links", strings.Join([]string{kind, hash}, "-")) log.Debug().Msgf("path: %s", fname) - f, err := os.Open(fname) + if !fileExists(fname) { + switch kind { + case "avatar": + ig, err := identicon.New("sour.is", 5, 3) + if err != nil { + writeText(w, 500, err.Error()) + return + } + + ii, err := ig.Draw(hash) + if err != nil { + writeText(w, 500, err.Error()) + return + } + + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(200) + err = ii.Png(clamp(128, 512, sizeW), w) + log.Error().Err(err).Send() + + return + default: + sp := strings.SplitN(pixl, ",", 2) + b, _ := base64.RawStdEncoding.DecodeString(sp[1]) + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(200) + if _, err := w.Write(b); err != nil { + log.Error().Err(err).Send() + } + return + } + } + + if !resize { + f, err := os.Open(fname) + if err != nil { + writeText(w, 500, err.Error()) + return + } + + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(200) + + _, err = io.Copy(w, f) + if err != nil { + log.Error().Err(err).Send() + } + return + } + + img, err := imaging.Open(fname, imaging.AutoOrientation(true)) if err != nil { writeText(w, 500, err.Error()) return } - _, err = io.Copy(w, f) - if err != nil { - writeText(w, 500, err.Error()) - return - } + img = imaging.Fill(img, sizeW, sizeH, imaging.Center, imaging.Lanczos) + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(200) + log.Debug().Msg("writing image") + err = imaging.Encode(w, img, imaging.PNG) + if err != nil { + log.Error().Err(err).Send() + } } func (app *avatarApp) Routes(r *chi.Mux) { r.MethodFunc("GET", "/{kind:avatar|bg|cover}/{hash}", app.get) } +func hashString(value string, h hash.Hash) string { + _, _ = h.Write([]byte(value)) + return fmt.Sprintf("%x", h.Sum(nil)) +} func hashMD5(name string) string { - h := md5.New() - _, _ = h.Write([]byte(name)) - - return fmt.Sprintf("%x", h.Sum(nil)) + return hashString(name, md5.New()) } -func hashSHA1(name string) string { - h := sha1.New() - _, _ = h.Write([]byte(name)) - - return fmt.Sprintf("%x", h.Sum(nil)) +func hashSHA256(name string) string { + return hashString(name, sha256.New()) } -func createLinks(path, kind, name string) error { +func (app *avatarApp) createLinks(kind, name string) error { if !strings.ContainsRune(name, '@') { return nil } @@ -172,40 +235,40 @@ func createLinks(path, kind, name string) error { name = strings.ToLower(name) hash := hashMD5(name) - link := filepath.Join(path, ".links", strings.Join([]string{kind, hash}, "-")) - err := replaceLink(src, link) + link := filepath.Join(app.path, ".links", strings.Join([]string{kind, hash}, "-")) + err := app.replaceLink(src, link) if err != nil { return err } - hash = hashSHA1(name) - link = filepath.Join(path, ".links", strings.Join([]string{kind, hash}, "-")) - err = replaceLink(src, link) + hash = hashSHA256(name) + link = filepath.Join(app.path, ".links", strings.Join([]string{kind, hash}, "-")) + err = app.replaceLink(src, link) return err } -func removeLinks(path, kind, name string) error { +func (app *avatarApp) removeLinks(kind, name string) error { if !strings.ContainsRune(name, '@') { return nil } name = strings.ToLower(name) hash := hashMD5(name) - link := filepath.Join(path, ".links", strings.Join([]string{kind, hash}, "-")) + link := filepath.Join(app.path, ".links", strings.Join([]string{kind, hash}, "-")) err := os.Remove(link) if err != nil { return err } - hash = hashSHA1(name) - link = filepath.Join(path, ".links", strings.Join([]string{kind, hash}, "-")) + hash = hashSHA256(name) + link = filepath.Join(app.path, ".links", strings.Join([]string{kind, hash}, "-")) err = os.Remove(link) return err } -func replaceLink(src, link string) error { +func (app *avatarApp) replaceLink(src, link string) error { if dst, err := os.Readlink(link); err != nil { if os.IsNotExist(err) { err = os.Symlink(src, link) @@ -228,3 +291,52 @@ func replaceLink(src, link string) error { return nil } + +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +func sizeByKind(kind string, size int) (sizeW int, sizeH int, resize bool) { + switch kind { + case "avatar": + if size == 0 { + size = 128 + } + sizeW = clamp(128, 640, size) + sizeH = sizeW + resize = true + + return + case "cover": + if size == 0 { + size = 940 + } + + sizeW = clamp(640, 1300, size) + sizeH = ratio(sizeW, 2.7) + resize = true + + return + default: + return 0, 0, false + } +} + +func ratio(size int, ratio float64) int { + return int(float64(size) / ratio) +} +func clamp(min, max, size int) int { + if size > max { + return max + } + + if size < min { + return min + } + + return size +} diff --git a/pkg/keyproofs/routes-wkd.go b/pkg/keyproofs/routes-wkd.go new file mode 100644 index 0000000..6c09617 --- /dev/null +++ b/pkg/keyproofs/routes-wkd.go @@ -0,0 +1,212 @@ +package keyproofs + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/fsnotify/fsnotify" + "github.com/go-chi/chi" + "github.com/rs/zerolog/log" + "github.com/sour-is/keyproofs/pkg/graceful" +) + +type wkdApp struct { + path string + domain string +} + +func NewWKDApp(ctx context.Context, path, domain string) (*wkdApp, error) { + log := log.Ctx(ctx) + + path = filepath.Clean(path) + app := &wkdApp{path: path} + err := app.CheckFiles(ctx) + if err != nil { + return nil, err + } + + watch, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + for _, typ := range []string{"keys"} { + err = watch.Add(filepath.Join(path, typ)) + if err != nil { + return nil, err + } + } + + log.Debug().Msg("startup wkd watcher") + wg := graceful.WaitGroup(ctx) + wg.Go(func() error { + for { + select { + case <-ctx.Done(): + log.Debug().Msg("shutdown wkd watcher") + return nil + case op := <-watch.Events: + log.Print(op) + switch op.Op { + case fsnotify.Create: + path = filepath.Dir(op.Name) + kind := filepath.Base(path) + name := filepath.Base(op.Name) + if err := app.createLinks(kind, name); err != nil { + fmt.Println(err) + } + case fsnotify.Remove, fsnotify.Rename: + path = filepath.Dir(op.Name) + kind := filepath.Base(path) + name := filepath.Base(op.Name) + if err := app.removeLinks(kind, name); err != nil { + log.Error().Err(err).Send() + } + default: + } + case err := <-watch.Errors: + fmt.Println(err) + } + } + }) + + return app, nil +} + +func (app *wkdApp) CheckFiles(ctx context.Context) error { + log := log.Ctx(ctx) + + for _, name := range []string{".links", "wkd"} { + log.Debug().Msgf("mkdir: %s", filepath.Join(app.path, name)) + err := os.MkdirAll(filepath.Join(app.path, name), 0700) + if err != nil { + return err + } + } + + return filepath.Walk(app.path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if info.Name() == ".links" { + return filepath.SkipDir + } + return nil + } + + path = filepath.Dir(path) + kind := filepath.Base(path) + name := info.Name() + + log.Debug().Msgf("link: %s %s %s", app.path, kind, name) + + return app.createLinks(kind, name) + }) +} + +func (app *wkdApp) get(w http.ResponseWriter, r *http.Request) { + log := log.Ctx(r.Context()) + + log.Print(r.Host) + + kind := chi.URLParam(r, "kind") + hash := chi.URLParam(r, "hash") + + if strings.ContainsRune(hash, '@') { + avatarHost, _, err := styleSRV(r.Context(), hash) + if err != nil { + writeText(w, 500, err.Error()) + return + } + hash = hashSHA256(strings.ToLower(hash)) + http.Redirect(w, r, fmt.Sprintf("https://%s/%s/%s?%s", avatarHost, kind, hash, r.URL.RawQuery), 301) + return + } + + fname := filepath.Join(app.path, ".links", strings.Join([]string{kind, hash}, "-")) + log.Debug().Msgf("path: %s", fname) + + f, err := os.Open(fname) + if err != nil { + writeText(w, 500, err.Error()) + return + } + + _, err = io.Copy(w, f) + if err != nil { + writeText(w, 500, err.Error()) + return + } +} + +func (app *wkdApp) Routes(r *chi.Mux) { + r.MethodFunc("GET", "/.well-known/openpgpkey/hu/{hash}", app.get) + r.MethodFunc("GET", "/.well-known/openpgpkey/hu/{domain}/{hash}", app.get) +} + +func (app *wkdApp) createLinks(kind, name string) error { + if !strings.ContainsRune(name, '@') { + return nil + } + + src := filepath.Join("..", kind, name) + name = strings.ToLower(name) + + hash := hashMD5(name) + link := filepath.Join(app.path, ".links", strings.Join([]string{kind, hash}, "-")) + err := app.replaceLink(src, link) + if err != nil { + return err + } + + return err +} + +func (app *wkdApp) removeLinks(kind, name string) error { + if !strings.ContainsRune(name, '@') { + return nil + } + name = strings.ToLower(name) + + hash := hashMD5(name) + link := filepath.Join(app.path, ".links", strings.Join([]string{kind, hash}, "-")) + err := os.Remove(link) + if err != nil { + return err + } + + hash = hashSHA256(name) + link = filepath.Join(app.path, ".links", strings.Join([]string{kind, hash}, "-")) + err = os.Remove(link) + + return err +} + +func (app *wkdApp) replaceLink(src, link string) error { + if dst, err := os.Readlink(link); err != nil { + if os.IsNotExist(err) { + err = os.Symlink(src, link) + if err != nil { + return err + } + } + } else { + if dst != src { + err = os.Remove(link) + if err != nil { + return err + } + err = os.Symlink(src, link) + if err != nil { + return err + } + } + } + + return nil +}