Import existing source code

This commit is contained in:
Paul-Christian Volkmer 2022-01-12 15:19:10 +01:00
commit 29de7cf4ac
10 changed files with 333 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.idea
idicon

32
Dockerfile Normal file
View File

@ -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"]

21
LICENSE Normal file
View File

@ -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.

41
README.adoc Normal file
View File

@ -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.

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module idicon
go 1.16
require github.com/gorilla/mux v1.8.0

2
go.sum Normal file
View File

@ -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=

138
idicon.go Normal file
View File

@ -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))
}

91
idicon_test.go Normal file
View File

@ -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")
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B