Add image uploader to the Edit Blog page
This commit is contained in:
parent
2fd5fccc5b
commit
c556f862e5
|
@ -1,6 +1,6 @@
|
||||||
{{ define "title" }}Update Blog{{ end }}
|
{{ define "title" }}Update Blog{{ end }}
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<form action="/blog/edit" method="POST">
|
<form name="blog-edit" action="/blog/edit" method="POST">
|
||||||
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||||
{{ if .Data.preview }}
|
{{ if .Data.preview }}
|
||||||
<div class="card mb-5">
|
<div class="card mb-5">
|
||||||
|
@ -98,9 +98,14 @@
|
||||||
id="body"
|
id="body"
|
||||||
placeholder="Post body goes here">{{ .Body }}</textarea>
|
placeholder="Post body goes here">{{ .Body }}</textarea>
|
||||||
|
|
||||||
<button id="ace-toggle-button" type="button" class="mt-2 btn btn-sm btn-secondary">
|
<div class="mt-2">
|
||||||
|
<button id="ace-toggle-button" type="button" class="btn btn-sm btn-secondary">
|
||||||
Toggle Rich Code Editor
|
Toggle Rich Code Editor
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span class="ml-2">Attach a file:</span>
|
||||||
|
<input type="file" id="attach-file-button" onChange="uploadFile()">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
@ -207,6 +212,43 @@ var ACE;
|
||||||
setSyntax("markdown");
|
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 = `<img alt="${filename}" src="${uri}" class="portrait">\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DISABLE_ACE_EDITOR) {
|
||||||
|
document.querySelector("#body").value += insert;
|
||||||
|
} else {
|
||||||
|
ACE.insert(insert);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setSyntax(lang) {
|
function setSyntax(lang) {
|
||||||
if (typeof(ACE) !== undefined) {
|
if (typeof(ACE) !== undefined) {
|
||||||
ACE.getSession().setMode("ace/mode/"+lang);
|
ACE.getSession().setMode("ace/mode/"+lang);
|
||||||
|
|
|
@ -15,6 +15,7 @@ func Register(r *mux.Router, authErrorFunc http.HandlerFunc) {
|
||||||
adminRouter.HandleFunc("/", indexHandler)
|
adminRouter.HandleFunc("/", indexHandler)
|
||||||
adminRouter.HandleFunc("/settings", settingsHandler)
|
adminRouter.HandleFunc("/settings", settingsHandler)
|
||||||
adminRouter.HandleFunc("/editor", editorHandler)
|
adminRouter.HandleFunc("/editor", editorHandler)
|
||||||
|
adminRouter.HandleFunc("/upload", uploadHandler)
|
||||||
|
|
||||||
r.PathPrefix("/admin").Handler(negroni.New(
|
r.PathPrefix("/admin").Handler(negroni.New(
|
||||||
negroni.HandlerFunc(auth.LoginRequired(authErrorFunc)),
|
negroni.HandlerFunc(auth.LoginRequired(authErrorFunc)),
|
||||||
|
|
150
src/controllers/admin/upload.go
Normal file
150
src/controllers/admin/upload.go
Normal file
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package responses
|
package responses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
@ -39,3 +40,15 @@ func Redirect(w http.ResponseWriter, location string) {
|
||||||
w.Header().Set("Location", location)
|
w.Header().Set("Location", location)
|
||||||
w.WriteHeader(http.StatusFound)
|
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)
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user