From 29de7cf4ac35505a9488121ceb59cb5437081d1a Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Wed, 12 Jan 2022 15:19:10 +0100 Subject: [PATCH] Import existing source code --- .gitignore | 3 + Dockerfile | 32 ++++ LICENSE | 21 +++ README.adoc | 41 ++++++ go.mod | 5 + go.sum | 2 + idicon.go | 138 ++++++++++++++++++ idicon_test.go | 91 ++++++++++++ .../1a79a4d60de6718e8e5b326e338ae533_v1.png | Bin 0 -> 263 bytes .../1a79a4d60de6718e8e5b326e338ae533_v2.png | Bin 0 -> 265 bytes 10 files changed, 333 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.adoc create mode 100644 go.mod create mode 100644 go.sum create mode 100644 idicon.go create mode 100644 idicon_test.go create mode 100644 testdata/1a79a4d60de6718e8e5b326e338ae533_v1.png create mode 100644 testdata/1a79a4d60de6718e8e5b326e338ae533_v2.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbe539b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea + +idicon \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0dd8204 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM golang:1.16-alpine AS build-env + +ENV USER=appuser +ENV UID=8000 + +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/null" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + "${USER}" + +WORKDIR /tmp/build +ADD . /tmp/build +# -ldlflags '-s' to strip binary +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app -ldflags '-w -s' + +### + +FROM scratch + +COPY --from=build-env /etc/passwd /etc/passwd +COPY --from=build-env /etc/group /etc/group +COPY --from=build-env /tmp/build/app /usr/local/bin/idicon + +USER appuser:appuser + +EXPOSE 8000 + +ENTRYPOINT ["/usr/local/bin/idicon"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4818d1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2021 Paul-Christian Volkmer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..2d1ff95 --- /dev/null +++ b/README.adoc @@ -0,0 +1,41 @@ += IdIcon + +Simple implementation of an identicon service. + +== Usage + +HTTP `GET` is used to request an identicon image. + +.... +curl http://localhost:8000/avatar/23463b99b62a72f26ed677cc556c44e8?s=100&c=v2 +.... + +Instead of requesting identicons for MD5 hashes of usernames or mail addresses, it is possible to use plain username and mail address. The will result in the same generated identicon. + +Use request query parameter `s` to request images with specified size. Default value is 80px. The size is limited to a maximum value of 512px. +Query parameter `c` will set color scheme. Available values are `v1` and `v2`. + +=== Server settings + +You can use `COLORSCHEME` in environment to define the default color scheme to be used. Fallback value will be `v2`. + +== Development and local start + +A *Golang* setup is required for development. File `idicon.go` contains the source code. Type + +.... +$ go run +.... + +to run the application on port 8000. + +=== Docker build + +Use `Dockerfile` to build a new image based on `scratch` image and start this image by typing + +.... +$ docker build -t idicon . +$ docker run -p 8000:8000 idicon +.... + +This will build the image and will start a new container listening on port 8000 for requests. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cce1834 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module idicon + +go 1.16 + +require github.com/gorilla/mux v1.8.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5350288 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= diff --git a/idicon.go b/idicon.go new file mode 100644 index 0000000..69cf797 --- /dev/null +++ b/idicon.go @@ -0,0 +1,138 @@ +package main + +import ( + "crypto/md5" + "encoding/hex" + "github.com/gorilla/mux" + "image" + "image/color" + "image/draw" + "image/png" + "log" + "net/http" + "os" + "regexp" + "strconv" + "strings" +) + +func colorV1(hash [16]byte) color.RGBA { + r := 32 + (hash[0]%16)/2<<4 + g := 32 + (hash[2]%16)/2<<4 + b := 32 + (hash[len(hash)-1]%16)/2<<4 + + if r > g && r > b { + r += 48 + } else if g > r && g > b { + g += 48 + } else if b > r && b > g { + b += 48 + } + return color.RGBA{r, g, b, 255} +} + +func colorV2(hash [16]byte) color.RGBA { + var palette = []color.RGBA{ + {0x3c, 0x38, 0x36, 0xff}, + {0xcc, 0x24, 0x1d, 0xff}, + {0x98, 0x97, 0x1a, 0xff}, + {0xd7, 0x99, 0x21, 0xff}, + {0x45, 0x85, 0x88, 0xff}, + {0xb1, 0x62, 0x86, 0xff}, + {0x68, 0x9d, 0x6a, 0xff}, + {0xa8, 0x99, 0x84, 0xff}, + } + return palette[hash[15]%8] +} + +func genIdIcon(id string, size int, f func([16]byte) color.RGBA) *image.NRGBA { + id = strings.ToLower(id) + blocks := 5 + if size > 512 { + size = 512 + } + + hash := hashBytes(id) + data := make([]bool, blocks*blocks) + for i := 0; i < len(hash)-1; i++ { + data[i] = hash[i]%2 != hash[i+1]%2 + } + return drawImage(mirrorData(data, blocks), blocks, size, f(hash)) +} + +func hashBytes(id string) [16]byte { + hash := [16]byte{} + md5RegExp := regexp.MustCompile("[a-f0-9]{32}") + if !md5RegExp.Match([]byte(id)) { + hash = md5.Sum([]byte(id)) + } else { + dec, _ := hex.DecodeString(id) + for idx, b := range dec { + hash[idx] = b + } + } + return hash +} + +func mirrorData(data []bool, blocks int) []bool { + for x := 0; x < blocks; x++ { + min := x*blocks + 1 + for y := 0; y < blocks; y++ { + a := ((blocks - x - 1) * blocks) + y + b := min + y - 1 + if data[a] { + data[b] = true + } + } + } + return data +} + +func drawImage(data []bool, blocks int, size int, c color.Color) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + + draw.Draw(img, img.Bounds(), &image.Uniform{color.Gray{240}}, image.Point{0, 0}, draw.Src) + + blockSize := size / (blocks + 1) + border := (size - (blocks * blockSize)) / 2 + + for x := border; x < blockSize*blocks+border; x++ { + bx := (x - border) / blockSize + for y := border; y < blockSize*blocks+border; y++ { + by := (y - border) / blockSize + idx := bx*blocks + by + if data[idx] && (bx < blocks || by < blocks) { + img.Set(x, y, c) + } + } + } + + return img +} + +func RequestHandler(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + + size, err := strconv.Atoi(r.URL.Query().Get("s")) + if err != nil { + size = 80 + } + + colorScheme := r.URL.Query().Get("c") + if colorScheme == "" { + colorScheme = os.Getenv("COLORSCHEME") + } + + w.Header().Add("Content-Type", "image/png") + if colorScheme == "v1" { + err = png.Encode(w, genIdIcon(id, size, colorV1)) + } else { + err = png.Encode(w, genIdIcon(id, size, colorV2)) + } +} + +func main() { + router := mux.NewRouter() + router.HandleFunc("/avatar/{id}", RequestHandler) + log.Fatal(http.ListenAndServe(":8000", router)) +} diff --git a/idicon_test.go b/idicon_test.go new file mode 100644 index 0000000..ce9bc15 --- /dev/null +++ b/idicon_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + _ "embed" + "github.com/gorilla/mux" + "image/png" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +//go:embed testdata/1a79a4d60de6718e8e5b326e338ae533_v1.png +var v1 []byte + +//go:embed testdata/1a79a4d60de6718e8e5b326e338ae533_v2.png +var v2 []byte + +func testRouter() *mux.Router { + router := mux.NewRouter() + router.HandleFunc("/avatar/{id}", RequestHandler) + return router +} + +func TestCorrectContentType(t *testing.T) { + req, err := http.NewRequest("GET", "/avatar/example", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + testRouter().ServeHTTP(rr, req) + + if ctype := rr.Header().Get("Content-Type"); ctype != "image/png" { + t.Errorf("content type header does not match: got %v want image/png", ctype) + } +} + +func TestCorrectResponseForV1ColorScheme(t *testing.T) { + req, err := http.NewRequest("GET", "/avatar/1a79a4d60de6718e8e5b326e338ae533?c=v1", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + testRouter().ServeHTTP(rr, req) + + if !reflect.DeepEqual(rr.Body.Bytes(), v1) { + t.Errorf("returned image does not match expected image") + } +} + +func TestCorrectResponseForV2ColorScheme(t *testing.T) { + req, err := http.NewRequest("GET", "/avatar/1a79a4d60de6718e8e5b326e338ae533?c=v2", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + testRouter().ServeHTTP(rr, req) + + if !reflect.DeepEqual(rr.Body.Bytes(), v2) { + t.Errorf("returned image does not match expected image") + } +} + +func TestIgnoreCase(t *testing.T) { + w1 := bytes.NewBuffer([]byte{}) + png.Encode(w1, genIdIcon("example", 80, colorV1)) + + w2 := bytes.NewBuffer([]byte{}) + png.Encode(w2, genIdIcon("Example", 80, colorV1)) + + if bytes.Compare(w1.Bytes(), w2.Bytes()) != 0 { + t.Errorf("resulting images do not match") + } +} + +func TestStringMatchesHash(t *testing.T) { + w1 := bytes.NewBuffer([]byte{}) + // MD5 of lowercase 'example' + png.Encode(w1, genIdIcon("1a79a4d60de6718e8e5b326e338ae533", 80, colorV2)) + + w2 := bytes.NewBuffer([]byte{}) + png.Encode(w2, genIdIcon("Example", 80, colorV2)) + + if bytes.Compare(w1.Bytes(), w2.Bytes()) != 0 { + t.Errorf("resulting images do not match") + } +} diff --git a/testdata/1a79a4d60de6718e8e5b326e338ae533_v1.png b/testdata/1a79a4d60de6718e8e5b326e338ae533_v1.png new file mode 100644 index 0000000000000000000000000000000000000000..2ef33b1ec14dcd2105564cb8a8ef9d1fc2832889 GIT binary patch literal 263 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51SA=YQ-1-ebDl1aAr*7p+&;+3WGLX4I92zI z(6%eO2PXwJiT4X0%A3LazW#n-%a0kCC+ygIP0ll}{C`6h+hs`~aq&H+_O{=?A2I&+ z*!a`dSgTe~DWM4fA}nqZ literal 0 HcmV?d00001 diff --git a/testdata/1a79a4d60de6718e8e5b326e338ae533_v2.png b/testdata/1a79a4d60de6718e8e5b326e338ae533_v2.png new file mode 100644 index 0000000000000000000000000000000000000000..1d63a344dcdcdbc12c7c9d08688248767d6964ae GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51SA=YQ-1-e3!W~HAr*7p+&;+3WGLX4I5qc> z(VAmYYmXW2S;nF-r`&SaK>FVQeTz9h8ZK8X%w8Ki>CU--j8}P<8PCwut9V`atI}3& zUSa({_kCf-8FyNEf#T;9teRi)EbHcer_Wz}_AVD+*X#`kOV}<;`lK7oJy=qB@Ol&X z$AeE3KQujBu!Bd(|w*9>xK)*3~y85}Sb4q9e0Ca+FsQ>@~ literal 0 HcmV?d00001