diff --git a/.gitignore b/.gitignore index bf034f6..751ea64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin/ +pvt-www/static/photos/ pkg/bundled/ public_html/.settings.json *.sqlite diff --git a/go.mod b/go.mod index cc021dc..eff89db 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.13 require ( github.com/albrow/forms v0.3.3 + github.com/edwvee/exiffix v0.0.0-20180602190213-b57537c92a6b github.com/gorilla/mux v1.7.3 github.com/gorilla/sessions v1.2.0 github.com/jinzhu/gorm v1.9.11 github.com/kirsle/blog v0.0.0-20191022175051-d78814b9c99b github.com/kirsle/golog v0.0.0-20180411020913-51290b4f9292 github.com/microcosm-cc/bluemonday v1.0.2 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/satori/go.uuid v1.2.0 github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470 github.com/urfave/negroni v1.0.0 diff --git a/go.sum b/go.sum index 5b0ce74..6bc1432 100644 --- a/go.sum +++ b/go.sum @@ -14,10 +14,12 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= +github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJMJXc/mA= github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/edwvee/exiffix v0.0.0-20180602190213-b57537c92a6b h1:6CBzNasH8+bKeFwr5Bt5JtALHLFN4iQp7sf4ShlP/ik= github.com/edwvee/exiffix v0.0.0-20180602190213-b57537c92a6b/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -80,6 +82,7 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -98,6 +101,7 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= @@ -131,6 +135,7 @@ golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443 h1:IcSOAf4PyMp3U3XbIEj1/xJ2BjNN2jWv7JoyOsMxXUU= golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81 h1:00VmoueYNlNz/aHIilyyQz/MHSqGoWJzpFv/HW8xpzI= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= diff --git a/pkg/controllers/static_files.go b/pkg/controllers/static_files.go index dcb1457..a29e594 100644 --- a/pkg/controllers/static_files.go +++ b/pkg/controllers/static_files.go @@ -51,5 +51,5 @@ func CatchAllHandler(w http.ResponseWriter, r *http.Request) { return } - http.ServeFile(w, r, "pvt-www/"+filepath) + responses.SendFile(w, r, filepath) } diff --git a/pkg/controllers/uploads.go b/pkg/controllers/uploads.go new file mode 100644 index 0000000..271cb5c --- /dev/null +++ b/pkg/controllers/uploads.go @@ -0,0 +1,192 @@ +package controllers + +import ( + "bytes" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "git.kirsle.net/apps/gophertype/pkg/console" + "git.kirsle.net/apps/gophertype/pkg/glue" + "git.kirsle.net/apps/gophertype/pkg/responses" + "github.com/edwvee/exiffix" + "github.com/nfnt/resize" +) + +// TODO: configurable max image width. +var ( + MaxImageWidth = 1280 + JpegQuality = 90 + ImagePath = "static/photos" // images folder for upload, relative to web root. +) + +func init() { + glue.Register(glue.Endpoint{ + Path: "/admin/upload", + Methods: []string{"GET", "POST"}, + Handler: UploadHandler, + }) +} + +// UploadHandler handles quick file uploads from the front-end for logged-in users. +func UploadHandler(w http.ResponseWriter, r *http.Request) { + // Parameters. + var ( + filetype = r.FormValue("type") // image only for now + filename = r.FormValue("filename") + ) + var buf bytes.Buffer + + type response struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Filename string `json:"filename,omitempty"` + URI string `json:"uri,omitempty"` + Checksum string `json:"checksum,omitempty"` + } + + // Validate the upload type. + if filetype != "image" { + responses.JSON(w, http.StatusBadRequest, response{ + Error: "Only 'image' type uploads supported for now.", + }) + return + } + + // Get the file from the form data. + file, header, err := r.FormFile("file") + if err != nil { + responses.JSON(w, http.StatusBadRequest, response{ + Error: err.Error(), + }) + return + } + defer file.Close() + + // Validate the extension is an image type. + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" { + responses.JSON(w, http.StatusBadRequest, response{ + Error: "Invalid file type, only common image types are supported: jpg, png, gif", + }) + return + } + + // Default filename? + if filename == "" { + filename = filepath.Base(header.Filename) + } + + // Read the file. + io.Copy(&buf, file) + binary := buf.Bytes() + + // Process and image and resize it down, strip metadata, etc. + binary, err = processImage(binary, ext) + if err != nil { + responses.JSON(w, http.StatusBadRequest, response{ + Error: "Resize error: " + err.Error(), + }) + } + + console.Info("Uploaded file named: %s name=%s", header.Filename, filename) + + // Write to the /static/photos directory of the user root. Ensure the path + // exists or create it if not. TODO + outputPath := filepath.Join("./pvt-www", "static", "photos") + if _, err := os.Stat(outputPath); os.IsNotExist(err) { + os.MkdirAll(outputPath, 0755) + } + + // Ensure the filename is unique. + filename = uniqueFilename("./pvt-www/static/photos", filename) + + // Write the output file. + console.Info("Uploaded image: %s", filename) + outfh, err := os.Create(filename) + if err != nil { + responses.JSON(w, http.StatusBadRequest, response{ + Error: err.Error(), + }) + return + } + defer outfh.Close() + outfh.Write(binary) + + responses.JSON(w, http.StatusOK, response{ + Success: true, + Filename: header.Filename, + URI: fmt.Sprintf("/static/photos/%s", filepath.Base(filename)), + }) +} + +// processImage manhandles an image's binary data, scaling it down to <= 1280 +// pixels and stripping off any metadata. +func processImage(input []byte, ext string) ([]byte, error) { + if ext == ".gif" { + return input, nil + } + + reader := bytes.NewReader(input) + + // Decode the image using exiffix, which will auto-rotate jpeg images etc. + // based on their EXIF values. + origImage, _, err := exiffix.Decode(reader) + if err != nil { + return input, err + } + + // Read the config to get the image width. + reader.Seek(0, io.SeekStart) + config, _, _ := image.DecodeConfig(reader) + width := config.Width + + // If the width is too great, scale it down. + if width > MaxImageWidth { + width = MaxImageWidth + } + newImage := resize.Resize(uint(width), 0, origImage, resize.Lanczos3) + + var output bytes.Buffer + switch ext { + case ".jpeg": + fallthrough + case ".jpg": + jpeg.Encode(&output, newImage, &jpeg.Options{ + Quality: JpegQuality, + }) + case ".png": + png.Encode(&output, newImage) + case ".gif": + gif.Encode(&output, newImage, nil) + } + + return output.Bytes(), nil +} + +// uniqueFilename gets a filename in a folder that doesn't already exist. +// Returns the file path with unique filename included. +func uniqueFilename(path string, filename string) string { + ext := filepath.Ext(filename) + basename := strings.TrimSuffix(filename, ext) + + // Try files. + var i = 1 + for { + if _, err := os.Stat(filepath.Join(path, filename)); !os.IsNotExist(err) { + filename = fmt.Sprintf("%s~%d%s", basename, i, ext) + i++ + continue + } + break + } + + return filepath.Join(path, filename) +} diff --git a/pkg/responses/json.go b/pkg/responses/json.go new file mode 100644 index 0000000..6c68a2c --- /dev/null +++ b/pkg/responses/json.go @@ -0,0 +1,15 @@ +package responses + +import ( + "encoding/json" + "net/http" +) + +// JSON sends a JSON payload as response. +func JSON(w http.ResponseWriter, statusCode int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + enc := json.NewEncoder(w) + enc.Encode(v) +} diff --git a/pkg/responses/template_functions.go b/pkg/responses/template_functions.go index d71d4f0..0c92fd3 100644 --- a/pkg/responses/template_functions.go +++ b/pkg/responses/template_functions.go @@ -17,6 +17,7 @@ var ExtraFuncs template.FuncMap func TemplateFuncs(r *http.Request) template.FuncMap { funcs := template.FuncMap{ "CSRF": CSRF(r), + "CSRFToken": CSRFToken(r), "FormValue": FormValue(r), "TestFunction": TestFunction(r), } @@ -41,6 +42,17 @@ func CSRF(r *http.Request) func() template.HTML { } } +// CSRFToken returns the current CSRF token as a string. +func CSRFToken(r *http.Request) func() template.HTML { + return func() template.HTML { + ctx := r.Context() + if token, ok := ctx.Value(session.CSRFKey).(string); ok { + return template.HTML(token) + } + return template.HTML("[error: csrf token not found in request context]") + } +} + // FormValue returns a form value (1st item only). func FormValue(r *http.Request) func(string) string { return func(key string) string { diff --git a/pvt-www/_builtin/blog/edit.gohtml b/pvt-www/_builtin/blog/edit.gohtml index 6b0c764..306a337 100644 --- a/pvt-www/_builtin/blog/edit.gohtml +++ b/pvt-www/_builtin/blog/edit.gohtml @@ -54,7 +54,17 @@ - + + + +
+
+ + Upload +
@@ -130,4 +140,62 @@
+ + {{ end }} diff --git a/pvt-www/_builtin/blog/view-post.gohtml b/pvt-www/_builtin/blog/view-post.gohtml index f5c8917..687b48d 100644 --- a/pvt-www/_builtin/blog/view-post.gohtml +++ b/pvt-www/_builtin/blog/view-post.gohtml @@ -43,12 +43,6 @@ -{{ $idStr := printf "%d" $Post.ID }} -{{ if $Post.EnableComments }} - {{ RenderComments .ResponseWriter .Request $Post.Title "post" $idStr }} -{{ else }} - {{ RenderCommentsRO .ResponseWriter .Request "post" $idStr }} -{{ end }} {{ if .CurrentUser.IsAdmin }}
@@ -60,4 +54,11 @@
{{ end }} +{{ $idStr := printf "%d" $Post.ID }} +{{ if $Post.EnableComments }} + {{ RenderComments .ResponseWriter .Request $Post.Title "post" $idStr }} +{{ else }} + {{ RenderCommentsRO .ResponseWriter .Request "post" $idStr }} +{{ end }} + {{ end }}