From f8922f013f1cd467bfc7331e696491bf9129dc0f Mon Sep 17 00:00:00 2001 From: Tomas Aparicio Date: Sun, 12 Apr 2015 02:52:47 +0200 Subject: [PATCH] feat(#15, #11, #7, #5, #13) --- README.md | 50 ++++++++++- benchmark/run.sh | 16 ---- controllers.go | 225 +++++++++++++++++++++++++++++++++++++++++++++++ error.go | 60 +++++++++++++ image.go | 160 ++++++++++++++++++++++++++++----- imaginary.go | 5 +- log.go | 74 ++++++++++++++++ options.go | 14 --- resize.go | 16 ---- server.go | 169 +++++------------------------------ type.go | 52 +++++++++++ 11 files changed, 626 insertions(+), 215 deletions(-) delete mode 100755 benchmark/run.sh create mode 100644 controllers.go create mode 100644 error.go create mode 100644 log.go delete mode 100644 options.go delete mode 100644 resize.go create mode 100644 type.go diff --git a/README.md b/README.md index 0241566..2eeadae 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ https://github.com/kr/heroku-buildpack-go.git - Extract area - Watermark (fully customizable text-based) - Format conversion (with additional quality/compression settings) +- Info (image size, format, orientation, alpha...) ## Performance @@ -67,15 +68,62 @@ Here you can see some performance test comparisons for multiple scenarios: ## Usage ``` +imaginary server + +Usage: + imaginary -p 80 + imaginary -h | -help + imaginary -v | -version + +Options: + -a bind address [default: *] + -p bind port [default: 8088] + -h, -help output help + -v, -version output version + -cpus Number of used cpu cores. + (default for current machine is 8 cores) +``` + +Start the server on a custom port +```bash imaginary -p 8080 ``` +You can pass it also as environment variable +```bash +POST=8080 imaginary +``` + ## HTTP API -#### GET /crop +#### GET /form + +Serve a very ugly HTML form just for testing purposes #### POST /crop +#### POST /resize + +#### POST /enlarge + +#### POST /zoom + +#### POST /thumbnail + +#### POST /rotate + +#### POST /flip + +#### POST /flop + +#### POST /extract + +#### POST /convert + +#### POST /info + +#### POST /watermark + ## License MIT - Tomas Aparicio diff --git a/benchmark/run.sh b/benchmark/run.sh deleted file mode 100755 index f236aef..0000000 --- a/benchmark/run.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -go build - -./imgine -p 8088 > /dev/null & -sleep 1 - -echo "Running resize tests" - -ab -c 1 -n 1 -v 4 \ - -p benchmark/data.txt \ - -T "multipart/form-data; boundary=1234567890" \ - http://localhost:8088/resize - -echo -echo "Running crop tests" diff --git a/controllers.go b/controllers.go new file mode 100644 index 0000000..2d41086 --- /dev/null +++ b/controllers.go @@ -0,0 +1,225 @@ +package main + +import ( + "io/ioutil" + "net/http" + "strconv" + "strings" +) + +func indexController(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("imaginary server " + Version)) +} + +const formText = ` + + +

Resize

+
+ + +
+

Crop

+
+ + +
+

Flip

+
+ + +
+

Flop

+
+ + +
+

Rotate (180)

+
+ + +
+

Thumbnail

+
+ + +
+

Zoom

+
+ + +
+

Watermark

+
+ + +
+

Convert

+
+ + +
+ + +` + +func formController(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(formText)) +} + +func infoController(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(formText)) +} + +func mainController(fn Operation) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + imageController(w, r, Operation(fn)) + } +} + +func imageController(w http.ResponseWriter, r *http.Request, Operation Operation) { + if r.Method != "POST" { + errorResponse(w, "Method not allowed for this endpoint", NOT_ALLOWED) + return + } + + var buf []byte + var err error + + contentType := r.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "multipart/") { + err = r.ParseMultipartForm(maxMemory) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + buf, err = getFormPayload(r) + if err != nil { + errorResponse(w, "Error while reading the body: "+err.Error(), BAD_REQUEST) + return + } + } else { + buf, _ = ioutil.ReadAll(r.Body) + } + + if len(buf) == 0 { + errorResponse(w, "Empty or invalid body", BAD_REQUEST) + return + } + + mimeType := http.DetectContentType(buf) + if IsImageTypeSupported(mimeType) == false { + errorResponse(w, "Unsupported media type: "+mimeType, UNSUPPORTED) + return + } + + opts, err := parseQueryParams(r) + if err != nil { + errorResponse(w, err.Error(), BAD_REQUEST) + return + } + + if opts.Type != "" { + format := ImageType(opts.Type) + if format == 0 { + errorResponse(w, "Unsupported image format: "+opts.Type, BAD_REQUEST) + return + } + mimeType = GetImageMimeType(format) + } + + debug("Options: %#v", opts) + body, err := Operation.Run(buf, opts) + if err != nil { + errorResponse(w, "Error while processing the image: "+err.Error(), BAD_REQUEST) + return + } + + w.Header().Set("Content-Type", mimeType) + w.Write(body) +} + +var allowedParams = map[string]string{ + "width": "int", + "height": "int", + "quality": "int", + "top": "int", + "left": "int", + "compression": "int", + "rotate": "int", + "margin": "int", + "factor": "int", + "opacity": "float", + "noreplicate": "bool", + "text": "string", + "font": "string", + "format": "string", + "type": "string", +} + +func parseQueryParams(r *http.Request) (ImageOptions, error) { + var val interface{} + query := r.URL.Query() + params := make(map[string]interface{}) + + for key, kind := range allowedParams { + key = strings.ToLower(key) + param := query.Get(key) + + switch kind { + case "int": + val, _ = strconv.Atoi(param) + break + case "float": + val, _ = strconv.ParseFloat(param, 64) + break + case "string": + val = param + break + case "bool": + if param != "" { + val = true + } + break + } + + params[key] = val + } + + opts := ImageOptions{ + Width: params["width"].(int), + Height: params["height"].(int), + Quality: params["quality"].(int), + Compression: params["compression"].(int), + Rotate: params["rotate"].(int), + Factor: params["factor"].(int), + Text: params["text"].(string), + Font: params["font"].(string), + Type: params["type"].(string), + Opacity: params["opacity"].(float64), + } + + return opts, nil +} + +func getFormPayload(r *http.Request) ([]byte, error) { + file, _, err := r.FormFile("file") + if err != nil { + return nil, err + } + defer file.Close() + + buf, err := ioutil.ReadAll(file) + if err != nil { + return nil, err + } + if len(buf) == 0 { + return nil, NewError("Empty payload", BAD_REQUEST) + } + + return buf, err +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..86992db --- /dev/null +++ b/error.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "net/http" + "strings" +) + +const ( + UNAVAILABLE uint8 = iota + BAD_REQUEST + NOT_ALLOWED + UNSUPPORTED + INTERNAL +) + +type Error struct { + Message string `json:"message,omitempty"` + Code uint8 `json:"code"` +} + +func (e *Error) JSON() []byte { + buf, _ := json.Marshal(e) + return buf +} + +func (e *Error) Error() string { + return e.Message +} + +func (e *Error) HttpCode() int { + code := http.StatusServiceUnavailable + switch e.Code { + case BAD_REQUEST: + code = http.StatusBadRequest + break + case NOT_ALLOWED: + code = http.StatusMethodNotAllowed + break + case UNSUPPORTED: + code = http.StatusUnsupportedMediaType + break + case INTERNAL: + code = http.StatusInternalServerError + break + } + return code +} + +func NewError(err string, code uint8) *Error { + err = strings.Replace(err, "\n", "", -1) + return &Error{err, code} +} + +func errorResponse(w http.ResponseWriter, msg string, code uint8) { + err := NewError(msg, code) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(err.HttpCode()) + w.Write(err.JSON()) +} diff --git a/image.go b/image.go index e0fb275..51870c3 100644 --- a/image.go +++ b/image.go @@ -1,34 +1,154 @@ package main import ( - "errors" - "net/http" - "strconv" + "gopkg.in/h2non/bimg.v0" ) type ImageOptions struct { - Width int - Height int - Quality int + Width int + Height int + Quality int + Compression int + Rotate int + Top int + Left int + Margin int + Factor int + Opacity float64 + Text string + Font string + Color string + Type string } -type Image struct { - Body []byte +type Operation func([]byte, ImageOptions) ([]byte, error) + +func (o Operation) Run(buf []byte, opts ImageOptions) ([]byte, error) { + return o(buf, opts) } -func validateParams(r *http.Request) (*ImageOptions, error) { - query := r.URL.Query() - width, _ := strconv.Atoi(query.Get("width")) - height, _ := strconv.Atoi(query.Get("height")) - quality, _ := strconv.Atoi(query.Get("quality")) +func bimgOptions(o ImageOptions) bimg.Options { + return bimg.Options{ + Width: o.Width, + Height: o.Height, + Quality: o.Quality, + Compression: o.Quality, + Type: ImageType(o.Type), + } +} - if width == 0 || height == 0 { - return nil, errors.New("Missing required height and width params") +func Resize(buf []byte, o ImageOptions) ([]byte, error) { + if o.Width == 0 || o.Height == 0 { + return nil, NewError("Missing required params: height, width", BAD_REQUEST) } - return &ImageOptions{ - Width: width, - Height: height, - Quality: quality, - }, nil + opts := bimgOptions(o) + return Process(buf, opts) +} + +func Enlarge(buf []byte, o ImageOptions) ([]byte, error) { + if o.Width == 0 || o.Height == 0 { + return nil, NewError("Missing required params: height, width", BAD_REQUEST) + } + + opts := bimgOptions(o) + opts.Enlarge = true + return Process(buf, opts) +} + +func Crop(buf []byte, o ImageOptions) ([]byte, error) { + opts := bimgOptions(o) + opts.Crop = true + return Process(buf, opts) +} + +func Rotate(buf []byte, o ImageOptions) ([]byte, error) { + if o.Rotate == 0 { + return nil, NewError("Missing rotate param", BAD_REQUEST) + } + + opts := bimgOptions(o) + opts.Rotate = bimg.Angle(o.Rotate) + return Process(buf, opts) +} + +func Flip(buf []byte, o ImageOptions) ([]byte, error) { + opts := bimgOptions(o) + opts.Flip = true + return Process(buf, opts) +} + +func Flop(buf []byte, o ImageOptions) ([]byte, error) { + opts := bimgOptions(o) + opts.Flop = true + return Process(buf, opts) +} + +func Thumbnail(buf []byte, o ImageOptions) ([]byte, error) { + if o.Width == 0 && o.Height == 0 { + return nil, NewError("Missing required params: width or height", BAD_REQUEST) + } + + opts := bimgOptions(o) + return Process(buf, opts) +} + +func Zoom(buf []byte, o ImageOptions) ([]byte, error) { + debug("Options: ") + if o.Width == 0 || o.Height == 0 || o.Factor == 0 { + return nil, NewError("Missing required params: width, height, factor", BAD_REQUEST) + } + + opts := bimgOptions(o) + //opts.Crop = true + opts.Zoom = o.Factor + return Process(buf, opts) +} + +func Convert(buf []byte, o ImageOptions) ([]byte, error) { + if o.Type == "" { + return nil, NewError("Missing required params: type", BAD_REQUEST) + } + + opts := bimgOptions(o) + return Process(buf, opts) +} + +func Watermark(buf []byte, o ImageOptions) ([]byte, error) { + if o.Text == "" { + return nil, NewError("Missing required params: text", BAD_REQUEST) + } + + opts := bimgOptions(o) + opts.Watermark.Text = o.Text + opts.Watermark.Font = o.Font + opts.Watermark.Margin = o.Margin + opts.Watermark.Opacity = float32(o.Opacity) + return Process(buf, opts) +} + +func Extract(buf []byte, o ImageOptions) ([]byte, error) { + if o.Top == 0 || o.Left == 0 { + return nil, NewError("Missing required params: top, left", BAD_REQUEST) + } + + opts := bimgOptions(o) + return Process(buf, opts) +} + +type ImageInfo struct { + Size int `json:"size"` + Width int `json:"width"` + Format string `json:"format"` + Height int `json:"height"` + Orientation int `json:"orientation"` + Alpha bool `json:"alpha"` +} + +func Info(buf []byte, o ImageOptions) ([]byte, error) { + return []byte{}, nil +} + +func Process(buf []byte, opts bimg.Options) ([]byte, error) { + return bimg.Resize(buf, opts) } diff --git a/imaginary.go b/imaginary.go index a9156a1..613948e 100644 --- a/imaginary.go +++ b/imaginary.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + . "github.com/tj/go-debug" "os" "runtime" "strconv" @@ -18,6 +19,8 @@ var ( aCpus = flag.Int("cpus", runtime.GOMAXPROCS(-1), "") ) +var debug = Debug("imaginary") + const usage = `imaginary server %s Usage: @@ -59,7 +62,7 @@ func main() { debug("imaginary server listening on port %d", port) - err := NewServer(opts) + err := Server(opts) if err != nil { fmt.Fprintf(os.Stderr, "cannot start the server: %s\n", err) os.Exit(1) diff --git a/log.go b/log.go new file mode 100644 index 0000000..d9b8eba --- /dev/null +++ b/log.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const formatPattern = "%s - - [%s] \"%s\" %d %d %.4f\n" + +type LogRecord struct { + http.ResponseWriter + status int + responseBytes int64 + ip string + method, uri, protocol string + time time.Time + elapsedTime time.Duration +} + +func (r *LogRecord) Log(out io.Writer) { + timeFormat := r.time.Format("02/Jan/2006 03:04:05") + request := fmt.Sprintf("%s %s %s", r.method, r.uri, r.protocol) + fmt.Fprintf(out, formatPattern, r.ip, timeFormat, request, r.status, r.responseBytes, r.elapsedTime.Seconds()) +} + +func (r *LogRecord) Write(p []byte) (int, error) { + written, err := r.ResponseWriter.Write(p) + r.responseBytes += int64(written) + return written, err +} + +func (r *LogRecord) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +type LogHandler struct { + handler http.Handler +} + +func NewLog(handler http.Handler) http.Handler { + return &LogHandler{handler} +} + +func (h *LogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + clientIP := r.RemoteAddr + if colon := strings.LastIndex(clientIP, ":"); colon != -1 { + clientIP = clientIP[:colon] + } + + record := &LogRecord{ + ResponseWriter: w, + ip: clientIP, + time: time.Time{}, + method: r.Method, + uri: r.RequestURI, + protocol: r.Proto, + status: http.StatusOK, + elapsedTime: time.Duration(0), + } + + startTime := time.Now() + h.handler.ServeHTTP(record, r) + finishTime := time.Now() + + record.time = finishTime.UTC() + record.elapsedTime = finishTime.Sub(startTime) + + record.Log(os.Stdout) +} diff --git a/options.go b/options.go deleted file mode 100644 index 0bc36fb..0000000 --- a/options.go +++ /dev/null @@ -1,14 +0,0 @@ -package main - -import ( - "gopkg.in/h2non/bimg.v0" -) - -func NewOptions(width int, height int, quality int) bimg.Options { - return bimg.Options{ - Width: width, - Height: height, - Quality: quality, - Crop: false, - } -} diff --git a/resize.go b/resize.go deleted file mode 100644 index 7ae7eaf..0000000 --- a/resize.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "gopkg.in/h2non/bimg.v0" -) - -func Resize(imageBuf []byte) ([]byte, error) { - options := bimg.Options{ - Width: 562, - Height: 562, - Crop: true, - Quality: 95, - } - - return bimg.Resize(imageBuf, options) -} diff --git a/server.go b/server.go index dcb6747..0084b98 100644 --- a/server.go +++ b/server.go @@ -1,13 +1,9 @@ package main import ( - "errors" "github.com/daaku/go.httpgzip" - "io/ioutil" - "log" "net/http" "strconv" - "strings" "time" ) @@ -18,27 +14,28 @@ type ServerOptions struct { Address string } -func NewServer(o ServerOptions) error { +func Server(o ServerOptions) error { mux := http.NewServeMux() - mux.Handle("/form", middleware(uploadForm)) - mux.Handle("/extract", middleware(processImage)) - mux.Handle("/enlarge", middleware(processImage)) - mux.Handle("/resize", middleware(processImage)) - mux.Handle("/crop", middleware(processImage)) - mux.Handle("/thumbnail", middleware(processImage)) - mux.Handle("/rotate", middleware(processImage)) - mux.Handle("/flip", middleware(processImage)) - mux.Handle("/flop", middleware(processImage)) - mux.Handle("/zoom", middleware(processImage)) - mux.Handle("/format", middleware(processImage)) - mux.Handle("/convert", middleware(processImage)) - mux.Handle("/watermark", middleware(processImage)) - mux.Handle("/", middleware(indexHandler)) + + mux.Handle("/", middleware(indexController)) + mux.Handle("/form", middleware(formController)) + mux.Handle("/resize", imageHandler(Resize)) + mux.Handle("/enlarge", imageHandler(Enlarge)) + mux.Handle("/extract", imageHandler(Extract)) + mux.Handle("/crop", imageHandler(Crop)) + mux.Handle("/rotate", imageHandler(Rotate)) + mux.Handle("/flip", imageHandler(Flip)) + mux.Handle("/flop", imageHandler(Flop)) + mux.Handle("/thumbnail", imageHandler(Thumbnail)) + mux.Handle("/zoom", imageHandler(Zoom)) + mux.Handle("/convert", imageHandler(Convert)) + mux.Handle("/watermark", imageHandler(Watermark)) + mux.Handle("/info", imageHandler(Info)) addr := o.Address + ":" + strconv.Itoa(o.Port) server := &http.Server{ Addr: addr, - Handler: mux, + Handler: NewLog(mux), ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20, @@ -47,146 +44,24 @@ func NewServer(o ServerOptions) error { return server.ListenAndServe() } -func handler(fn func(http.ResponseWriter, *http.Request)) http.Handler { - return middleware(http.HandlerFunc(fn)) +func imageHandler(fn Operation) http.Handler { + return middleware(mainController(fn)) } func middleware(fn func(http.ResponseWriter, *http.Request)) http.Handler { next := httpgzip.NewHandler(http.HandlerFunc(fn)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Server", "imaginary "+Version) - logger(r) - validateRequest(next).ServeHTTP(w, r) + validate(next).ServeHTTP(w, r) }) } -func logger(r *http.Request) { - remoteAddr := r.Header.Get("X-Forwarded-For") - if remoteAddr == "" { - remoteAddr = r.RemoteAddr - } - log.Printf("[%s] %s %q\n", r.Method, remoteAddr, r.URL.String()) -} - -func validateRequest(next http.Handler) http.Handler { +func validate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" && r.Method != "POST" { - w.WriteHeader(http.StatusMethodNotAllowed) + errorResponse(w, "Method not allowed: "+r.Method, NOT_ALLOWED) return } - - if r.Method == "POST" && r.ContentLength == 0 { - w.WriteHeader(http.StatusBadRequest) - return - } - next.ServeHTTP(w, r) }) } - -func indexHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte("imaginary server " + Version)) -} - -const formText = ` - - -

Resize

-
- - -
-

Crop

-
- - -
-

Flip

-
- - -
-

Thumbnail

-
- - -
- - -` - -func uploadForm(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(formText)) -} - -func processImage(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - contentType := r.Header.Get("Content-Type") - if contentType == "" { - buf, err := ioutil.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - contentType = http.DetectContentType(buf) - contentType = strings.Split(contentType, ";")[0] - } - - // temporal - if !strings.HasPrefix(contentType, "multipart/") { - w.WriteHeader(http.StatusUnsupportedMediaType) - return - } - - err := r.ParseMultipartForm(maxMemory) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - file, mimeType, err := getPayload(r) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - if mimeType != "image/jpeg" && mimeType != "image/png" { - w.WriteHeader(http.StatusUnsupportedMediaType) - return - } - - body, err := Resize(file) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - w.Header().Set("Content-Type", mimeType) - w.Write(body) -} - -func getPayload(r *http.Request) ([]byte, string, error) { - file, _, err := r.FormFile("file") - if err != nil { - return nil, "", err - } - defer file.Close() - - buf, err := ioutil.ReadAll(file) - if err != nil { - return nil, "", err - } - if len(buf) == 0 { - return nil, "", errors.New("Empty payload") - } - - mimeType := http.DetectContentType(buf) - - return buf, mimeType, err -} diff --git a/type.go b/type.go new file mode 100644 index 0000000..6f642f1 --- /dev/null +++ b/type.go @@ -0,0 +1,52 @@ +package main + +import ( + "gopkg.in/h2non/bimg.v0" + "strings" +) + +func ExtractImageTypeFromMime(mime string) string { + mime = strings.Split(mime, " ")[0] + part := strings.Split(mime, "/") + if len(part) < 2 { + return "" + } + return strings.ToLower(part[1]) +} + +func IsImageTypeSupported(mime string) bool { + format := ExtractImageTypeFromMime(mime) + return bimg.IsTypeNameSupported(format) +} + +func ImageType(mime string) bimg.ImageType { + format := bimg.UNKNOWN + switch strings.ToLower(mime) { + case "jpeg": + format = bimg.JPEG + break + case "png": + format = bimg.PNG + break + case "webp": + format = bimg.WEBP + break + case "tiff": + format = bimg.TIFF + break + } + return format +} + +func GetImageMimeType(code bimg.ImageType) string { + mime := "image/jpeg" + switch code { + case bimg.PNG: + mime = "image/png" + break + case bimg.WEBP: + mime = "image/webp" + break + } + return mime +}