@@ -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 = `
\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)
+}