Image uploader on Blog Edit page
This commit is contained in:
parent
91e3bdaa53
commit
c1995efb7a
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
bin/
|
||||
pvt-www/static/photos/
|
||||
pkg/bundled/
|
||||
public_html/.settings.json
|
||||
*.sqlite
|
||||
|
|
2
go.mod
2
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
|
||||
|
|
5
go.sum
5
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=
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
192
pkg/controllers/uploads.go
Normal file
192
pkg/controllers/uploads.go
Normal file
|
@ -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)
|
||||
}
|
15
pkg/responses/json.go
Normal file
15
pkg/responses/json.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -54,7 +54,17 @@
|
|||
</label>
|
||||
</div>
|
||||
<label for="body">Body</label>
|
||||
<textarea class="form-control" cols="40" rows="12" name="body">{{ $Post.Body }}</textarea>
|
||||
<textarea class="form-control" cols="40" rows="12" name="body" id="body">{{ $Post.Body }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Attach Image</label><br>
|
||||
<a href="#" id="attach-img-link">+ Upload</a>
|
||||
<div id="attach-img-form" style="display: none; border: 1px dashed #333; padding: 1rem">
|
||||
File: <input type="file" class="form-control" id="attach-img-file"><br>
|
||||
Filename: <input type="text" class="form-control" id="attach-img-filename"><br>
|
||||
<button type="button" class="btn btn-primary" id="attach-img-button">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
@ -130,4 +140,62 @@
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Image uploader.
|
||||
let $link = document.querySelector("#attach-img-link");
|
||||
let $form = document.querySelector("#attach-img-form");
|
||||
let $file = document.querySelector("#attach-img-file");
|
||||
let $filename = document.querySelector("#attach-img-filename");
|
||||
let $button = document.querySelector("#attach-img-button");
|
||||
|
||||
$link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
$form.style.display = "block";
|
||||
$link.style.display = "none";
|
||||
});
|
||||
$file.addEventListener("change", (e) => {
|
||||
let file = $file.files[0];
|
||||
console.log(file);
|
||||
$filename.value = file.name;
|
||||
});
|
||||
$button.addEventListener("click", (e) => {
|
||||
let syntax = document.querySelector("input[name='content-type']:checked").value;
|
||||
let file = $file.files[0];
|
||||
|
||||
var data = new FormData();
|
||||
data.append("type", "image");
|
||||
data.append("file", file);
|
||||
data.append("filename", $filename.value);
|
||||
data.append("_csrf", "{{ CSRFToken }}");
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
document.querySelector("#body").value += insert;
|
||||
|
||||
$file.value = "";
|
||||
$filename.value = "";
|
||||
$form.style.display = "none";
|
||||
$link.style.display = "inline";
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
|
|
|
@ -43,12 +43,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{ $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 }}
|
||||
<div class="alert alert-secondary">
|
||||
|
@ -60,4 +54,11 @@
|
|||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ $idStr := printf "%d" $Post.ID }}
|
||||
{{ if $Post.EnableComments }}
|
||||
{{ RenderComments .ResponseWriter .Request $Post.Title "post" $idStr }}
|
||||
{{ else }}
|
||||
{{ RenderCommentsRO .ResponseWriter .Request "post" $idStr }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
|
|
Loading…
Reference in New Issue
Block a user