362 lines
8.8 KiB
Go
362 lines
8.8 KiB
Go
/*
|
|
* jQuery File Upload Plugin GAE Go Example
|
|
* https://github.com/blueimp/jQuery-File-Upload
|
|
*
|
|
* Copyright 2011, Sebastian Tschan
|
|
* https://blueimp.net
|
|
*
|
|
* Licensed under the MIT license:
|
|
* http://www.opensource.org/licenses/MIT
|
|
*/
|
|
|
|
package app
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/disintegration/gift"
|
|
"golang.org/x/net/context"
|
|
"google.golang.org/appengine"
|
|
"google.golang.org/appengine/memcache"
|
|
"hash/crc32"
|
|
"image"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"log"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
WEBSITE = "https://blueimp.github.io/jQuery-File-Upload/"
|
|
MIN_FILE_SIZE = 1 // bytes
|
|
// Max file size is memcache limit (1MB) minus key size minus overhead:
|
|
MAX_FILE_SIZE = 999000 // bytes
|
|
IMAGE_TYPES = "image/(gif|p?jpeg|(x-)?png)"
|
|
ACCEPT_FILE_TYPES = IMAGE_TYPES
|
|
THUMB_MAX_WIDTH = 80
|
|
THUMB_MAX_HEIGHT = 80
|
|
EXPIRATION_TIME = 300 // seconds
|
|
// If empty, only allow redirects to the referer protocol+host.
|
|
// Set to a regexp string for custom pattern matching:
|
|
REDIRECT_ALLOW_TARGET = ""
|
|
)
|
|
|
|
var (
|
|
imageTypes = regexp.MustCompile(IMAGE_TYPES)
|
|
acceptFileTypes = regexp.MustCompile(ACCEPT_FILE_TYPES)
|
|
thumbSuffix = "." + fmt.Sprint(THUMB_MAX_WIDTH) + "x" +
|
|
fmt.Sprint(THUMB_MAX_HEIGHT)
|
|
)
|
|
|
|
func escape(s string) string {
|
|
return strings.Replace(url.QueryEscape(s), "+", "%20", -1)
|
|
}
|
|
|
|
func extractKey(r *http.Request) string {
|
|
// Use RequestURI instead of r.URL.Path, as we need the encoded form:
|
|
path := strings.Split(r.RequestURI, "?")[0]
|
|
// Also adjust double encoded slashes:
|
|
return strings.Replace(path[1:], "%252F", "%2F", -1)
|
|
}
|
|
|
|
func check(err error) {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
type FileInfo struct {
|
|
Key string `json:"-"`
|
|
ThumbnailKey string `json:"-"`
|
|
Url string `json:"url,omitempty"`
|
|
ThumbnailUrl string `json:"thumbnailUrl,omitempty"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Size int64 `json:"size"`
|
|
Error string `json:"error,omitempty"`
|
|
DeleteUrl string `json:"deleteUrl,omitempty"`
|
|
DeleteType string `json:"deleteType,omitempty"`
|
|
}
|
|
|
|
func (fi *FileInfo) ValidateType() (valid bool) {
|
|
if acceptFileTypes.MatchString(fi.Type) {
|
|
return true
|
|
}
|
|
fi.Error = "Filetype not allowed"
|
|
return false
|
|
}
|
|
|
|
func (fi *FileInfo) ValidateSize() (valid bool) {
|
|
if fi.Size < MIN_FILE_SIZE {
|
|
fi.Error = "File is too small"
|
|
} else if fi.Size > MAX_FILE_SIZE {
|
|
fi.Error = "File is too big"
|
|
} else {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (fi *FileInfo) CreateUrls(r *http.Request, c context.Context) {
|
|
u := &url.URL{
|
|
Scheme: r.URL.Scheme,
|
|
Host: appengine.DefaultVersionHostname(c),
|
|
Path: "/",
|
|
}
|
|
uString := u.String()
|
|
fi.Url = uString + fi.Key
|
|
fi.DeleteUrl = fi.Url
|
|
fi.DeleteType = "DELETE"
|
|
if fi.ThumbnailKey != "" {
|
|
fi.ThumbnailUrl = uString + fi.ThumbnailKey
|
|
}
|
|
}
|
|
|
|
func (fi *FileInfo) SetKey(checksum uint32) {
|
|
fi.Key = escape(string(fi.Type)) + "/" +
|
|
escape(fmt.Sprint(checksum)) + "/" +
|
|
escape(string(fi.Name))
|
|
}
|
|
|
|
func (fi *FileInfo) createThumb(buffer *bytes.Buffer, c context.Context) {
|
|
if imageTypes.MatchString(fi.Type) {
|
|
src, _, err := image.Decode(bytes.NewReader(buffer.Bytes()))
|
|
check(err)
|
|
filter := gift.New(gift.ResizeToFit(
|
|
THUMB_MAX_WIDTH,
|
|
THUMB_MAX_HEIGHT,
|
|
gift.LanczosResampling,
|
|
))
|
|
dst := image.NewNRGBA(filter.Bounds(src.Bounds()))
|
|
filter.Draw(dst, src)
|
|
buffer.Reset()
|
|
bWriter := bufio.NewWriter(buffer)
|
|
switch fi.Type {
|
|
case "image/jpeg", "image/pjpeg":
|
|
err = jpeg.Encode(bWriter, dst, nil)
|
|
case "image/gif":
|
|
err = gif.Encode(bWriter, dst, nil)
|
|
default:
|
|
err = png.Encode(bWriter, dst)
|
|
}
|
|
check(err)
|
|
bWriter.Flush()
|
|
thumbnailKey := fi.Key + thumbSuffix + filepath.Ext(fi.Name)
|
|
item := &memcache.Item{
|
|
Key: thumbnailKey,
|
|
Value: buffer.Bytes(),
|
|
}
|
|
err = memcache.Set(c, item)
|
|
check(err)
|
|
fi.ThumbnailKey = thumbnailKey
|
|
}
|
|
}
|
|
|
|
func handleUpload(r *http.Request, p *multipart.Part) (fi *FileInfo) {
|
|
fi = &FileInfo{
|
|
Name: p.FileName(),
|
|
Type: p.Header.Get("Content-Type"),
|
|
}
|
|
if !fi.ValidateType() {
|
|
return
|
|
}
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
log.Println(rec)
|
|
fi.Error = rec.(error).Error()
|
|
}
|
|
}()
|
|
var buffer bytes.Buffer
|
|
hash := crc32.NewIEEE()
|
|
mw := io.MultiWriter(&buffer, hash)
|
|
lr := &io.LimitedReader{R: p, N: MAX_FILE_SIZE + 1}
|
|
_, err := io.Copy(mw, lr)
|
|
check(err)
|
|
fi.Size = MAX_FILE_SIZE + 1 - lr.N
|
|
if !fi.ValidateSize() {
|
|
return
|
|
}
|
|
fi.SetKey(hash.Sum32())
|
|
item := &memcache.Item{
|
|
Key: fi.Key,
|
|
Value: buffer.Bytes(),
|
|
}
|
|
context := appengine.NewContext(r)
|
|
err = memcache.Set(context, item)
|
|
check(err)
|
|
fi.createThumb(&buffer, context)
|
|
fi.CreateUrls(r, context)
|
|
return
|
|
}
|
|
|
|
func getFormValue(p *multipart.Part) string {
|
|
var b bytes.Buffer
|
|
io.CopyN(&b, p, int64(1<<20)) // Copy max: 1 MiB
|
|
return b.String()
|
|
}
|
|
|
|
func handleUploads(r *http.Request) (fileInfos []*FileInfo) {
|
|
fileInfos = make([]*FileInfo, 0)
|
|
mr, err := r.MultipartReader()
|
|
check(err)
|
|
r.Form, err = url.ParseQuery(r.URL.RawQuery)
|
|
check(err)
|
|
part, err := mr.NextPart()
|
|
for err == nil {
|
|
if name := part.FormName(); name != "" {
|
|
if part.FileName() != "" {
|
|
fileInfos = append(fileInfos, handleUpload(r, part))
|
|
} else {
|
|
r.Form[name] = append(r.Form[name], getFormValue(part))
|
|
}
|
|
}
|
|
part, err = mr.NextPart()
|
|
}
|
|
return
|
|
}
|
|
|
|
func validateRedirect(r *http.Request, redirect string) bool {
|
|
if redirect != "" {
|
|
var redirectAllowTarget *regexp.Regexp
|
|
if REDIRECT_ALLOW_TARGET != "" {
|
|
redirectAllowTarget = regexp.MustCompile(REDIRECT_ALLOW_TARGET)
|
|
} else {
|
|
referer := r.Referer()
|
|
if referer == "" {
|
|
return false
|
|
}
|
|
refererUrl, err := url.Parse(referer)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
redirectAllowTarget = regexp.MustCompile("^" + regexp.QuoteMeta(
|
|
refererUrl.Scheme+"://"+refererUrl.Host+"/",
|
|
))
|
|
}
|
|
return redirectAllowTarget.MatchString(redirect)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func get(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/" {
|
|
http.Redirect(w, r, WEBSITE, http.StatusFound)
|
|
return
|
|
}
|
|
// Use RequestURI instead of r.URL.Path, as we need the encoded form:
|
|
key := extractKey(r)
|
|
parts := strings.Split(key, "/")
|
|
if len(parts) == 3 {
|
|
context := appengine.NewContext(r)
|
|
item, err := memcache.Get(context, key)
|
|
if err == nil {
|
|
w.Header().Add("X-Content-Type-Options", "nosniff")
|
|
contentType, _ := url.QueryUnescape(parts[0])
|
|
if !imageTypes.MatchString(contentType) {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
w.Header().Add("Content-Type", contentType)
|
|
w.Header().Add(
|
|
"Cache-Control",
|
|
fmt.Sprintf("public,max-age=%d", EXPIRATION_TIME),
|
|
)
|
|
w.Write(item.Value)
|
|
return
|
|
}
|
|
}
|
|
http.Error(w, "404 Not Found", http.StatusNotFound)
|
|
}
|
|
|
|
func post(w http.ResponseWriter, r *http.Request) {
|
|
result := make(map[string][]*FileInfo, 1)
|
|
result["files"] = handleUploads(r)
|
|
b, err := json.Marshal(result)
|
|
check(err)
|
|
if redirect := r.FormValue("redirect"); validateRedirect(r, redirect) {
|
|
if strings.Contains(redirect, "%s") {
|
|
redirect = fmt.Sprintf(
|
|
redirect,
|
|
escape(string(b)),
|
|
)
|
|
}
|
|
http.Redirect(w, r, redirect, http.StatusFound)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
jsonType := "application/json"
|
|
if strings.Index(r.Header.Get("Accept"), jsonType) != -1 {
|
|
w.Header().Set("Content-Type", jsonType)
|
|
}
|
|
fmt.Fprintln(w, string(b))
|
|
}
|
|
|
|
func delete(w http.ResponseWriter, r *http.Request) {
|
|
key := extractKey(r)
|
|
parts := strings.Split(key, "/")
|
|
if len(parts) == 3 {
|
|
result := make(map[string]bool, 1)
|
|
context := appengine.NewContext(r)
|
|
err := memcache.Delete(context, key)
|
|
if err == nil {
|
|
result[key] = true
|
|
contentType, _ := url.QueryUnescape(parts[0])
|
|
if imageTypes.MatchString(contentType) {
|
|
thumbnailKey := key + thumbSuffix + filepath.Ext(parts[2])
|
|
err := memcache.Delete(context, thumbnailKey)
|
|
if err == nil {
|
|
result[thumbnailKey] = true
|
|
}
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
b, err := json.Marshal(result)
|
|
check(err)
|
|
fmt.Fprintln(w, string(b))
|
|
} else {
|
|
http.Error(w, "405 Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func handle(w http.ResponseWriter, r *http.Request) {
|
|
params, err := url.ParseQuery(r.URL.RawQuery)
|
|
check(err)
|
|
w.Header().Add("Access-Control-Allow-Origin", "*")
|
|
w.Header().Add(
|
|
"Access-Control-Allow-Methods",
|
|
"OPTIONS, HEAD, GET, POST, DELETE",
|
|
)
|
|
w.Header().Add(
|
|
"Access-Control-Allow-Headers",
|
|
"Content-Type, Content-Range, Content-Disposition",
|
|
)
|
|
switch r.Method {
|
|
case "OPTIONS", "HEAD":
|
|
return
|
|
case "GET":
|
|
get(w, r)
|
|
case "POST":
|
|
if len(params["_method"]) > 0 && params["_method"][0] == "DELETE" {
|
|
delete(w, r)
|
|
} else {
|
|
post(w, r)
|
|
}
|
|
case "DELETE":
|
|
delete(w, r)
|
|
default:
|
|
http.Error(w, "501 Not Implemented", http.StatusNotImplemented)
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
http.HandleFunc("/", handle)
|
|
}
|