This commit is contained in:
Tomas Aparicio
2015-04-12 02:52:47 +02:00
parent 47f7e6615d
commit f8922f013f
11 changed files with 626 additions and 215 deletions
+49 -1
View File
@@ -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 <addr> bind address [default: *]
-p <port> bind port [default: 8088]
-h, -help output help
-v, -version output version
-cpus <num> 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
-16
View File
@@ -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"
+225
View File
@@ -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 = `
<html>
<body>
<h1>Resize</h1>
<form method="POST" action="/resize?width=300&height=200&type=png" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Crop</h1>
<form method="POST" action="/crop" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Flip</h1>
<form method="POST" action="/flip" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Flop</h1>
<form method="POST" action="/flop" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Rotate (180)</h1>
<form method="POST" action="/rotate?rotate=180" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Thumbnail</h1>
<form method="POST" action="/thumbnail?width=100" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Zoom</h1>
<form method="POST" action="/zoom?factor=2&width=300&height=300" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Watermark</h1>
<form method="POST" action="/watermark?text=Hello&font=sans%2014&opacity=0.5" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Convert</h1>
<form method="POST" action="/convert?type=png" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
</body>
</html>
`
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
}
+60
View File
@@ -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())
}
+140 -20
View File
@@ -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)
}
+4 -1
View File
@@ -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)
+74
View File
@@ -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)
}
-14
View File
@@ -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,
}
}
-16
View File
@@ -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)
}
+22 -147
View File
@@ -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 = `
<html>
<body>
<h1>Resize</h1>
<form method="POST" action="/resize?width=400&height=300" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Crop</h1>
<form method="POST" action="/crop" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Flip</h1>
<form method="POST" action="/flip" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<h1>Thumbnail</h1>
<form method="POST" action="/thumbnail" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
</body>
</html>
`
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
}
+52
View File
@@ -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
}