diff --git a/root/blog/edit.gohtml b/root/blog/edit.gohtml index 920c54b..8585a63 100644 --- a/root/blog/edit.gohtml +++ b/root/blog/edit.gohtml @@ -1,6 +1,6 @@ {{ define "title" }}Update Blog{{ end }} {{ define "content" }} -
+ {{ if .Data.preview }}
@@ -98,9 +98,14 @@ id="body" placeholder="Post body goes here">{{ .Body }} - +
+ + + Attach a file: + +
@@ -207,6 +212,43 @@ var ACE; setSyntax("markdown"); })(); +function uploadFile() { + let $input = document.querySelector("#attach-file-button"); + let syntax = document.querySelector("input[name='content-type']:checked").value; + let file = $input.files[0]; + + var data = new FormData(); + data.append("file", file); + data.append("_csrf", "{{ .CSRF }}"); + + fetch("/admin/upload", { + method: "POST", + body: data, + credentials: "same-origin", + cache: "no-cache" + }).then(resp => resp.json()).then(resp => { + if (!resp.success) { + window.alert(resp.error); + return; + } + + let filename = resp.filename; + let uri = resp.uri; + let insert = `![${filename}](${uri})\n`; + if (syntax === "html") { + insert = `${filename}\n`; + } + + if (DISABLE_ACE_EDITOR) { + document.querySelector("#body").value += insert; + } else { + ACE.insert(insert); + } + + $input.value = ""; + }); +} + function setSyntax(lang) { if (typeof(ACE) !== undefined) { ACE.getSession().setMode("ace/mode/"+lang); diff --git a/src/controllers/admin/admin.go b/src/controllers/admin/admin.go index 861d279..cda5423 100644 --- a/src/controllers/admin/admin.go +++ b/src/controllers/admin/admin.go @@ -15,6 +15,7 @@ func Register(r *mux.Router, authErrorFunc http.HandlerFunc) { adminRouter.HandleFunc("/", indexHandler) adminRouter.HandleFunc("/settings", settingsHandler) adminRouter.HandleFunc("/editor", editorHandler) + adminRouter.HandleFunc("/upload", uploadHandler) r.PathPrefix("/admin").Handler(negroni.New( negroni.HandlerFunc(auth.LoginRequired(authErrorFunc)), diff --git a/src/controllers/admin/upload.go b/src/controllers/admin/upload.go new file mode 100644 index 0000000..b11182b --- /dev/null +++ b/src/controllers/admin/upload.go @@ -0,0 +1,150 @@ +package admin + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "image" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "image/gif" + "image/jpeg" + "image/png" + + "github.com/edwvee/exiffix" + "github.com/kirsle/blog/src/log" + "github.com/kirsle/blog/src/render" + "github.com/kirsle/blog/src/responses" + "github.com/nfnt/resize" +) + +// TODO: configurable max image width. +var ( + MaxImageWidth = 1280 + JpegQuality = 90 +) + +// 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 +} + +func uploadHandler(w http.ResponseWriter, r *http.Request) { + 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"` + } + + // 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 + } + + // 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(), + }) + } + + // Make a checksum of it. + sha := sha256.New() + sha.Write(binary) + checksum := hex.EncodeToString(sha.Sum(nil)) + + log.Info("Uploaded file names: %s Checksum is: %s", header.Filename, checksum) + + // Write to the /static/photos directory of the user root. Ensure the path + // exists or create it if not. + outputPath := filepath.Join(*render.UserRoot, "static", "photos") + if _, err := os.Stat(outputPath); os.IsNotExist(err) { + os.MkdirAll(outputPath, 0755) + } + + // Write the output file. + filename := filepath.Join(outputPath, checksum+ext) + 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%s", checksum, ext), + Checksum: checksum, + }) +} diff --git a/src/responses/responses.go b/src/responses/responses.go index 43a4878..d209362 100644 --- a/src/responses/responses.go +++ b/src/responses/responses.go @@ -1,6 +1,7 @@ package responses import ( + "encoding/json" "fmt" "net/http" @@ -39,3 +40,15 @@ func Redirect(w http.ResponseWriter, location string) { w.Header().Set("Location", location) w.WriteHeader(http.StatusFound) } + +// JSON serializes a JSON response to the browser. +func JSON(w http.ResponseWriter, statusCode int, v interface{}) { + w.Header().Set("Content-Type", "application/json; encoding=utf-8") + w.WriteHeader(statusCode) + + serial, err := json.MarshalIndent(v, "", "\t") + if err != nil { + serial, _ = json.Marshal(err.Error()) + } + w.Write(serial) +}