gophertype/pkg/controllers/uploads.go
Noah 383b5d7591 Age Gate, Legacy kirsle/blog Migration Program
* Add the Age Gate middleware for NSFW sites.
* Cache thumbnail images from blog entries.
* Implement the user-root properly for loading web assets.
2020-02-17 15:50:04 -08:00

196 lines
4.7 KiB
Go

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"
"git.kirsle.net/apps/gophertype/pkg/settings"
"github.com/edwvee/exiffix"
"github.com/nfnt/resize"
)
// TODO: configurable max image width.
var (
MaxImageWidth = 1280
JpegQuality = 90
// images folder for upload, relative to web root.
ImagePath = filepath.Join("static", "photos")
)
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.
outputPath := filepath.Join(settings.UserRoot, ImagePath)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
os.MkdirAll(outputPath, 0755)
}
// Ensure the filename is unique.
filename = uniqueFilename(filepath.Join(settings.UserRoot, ImagePath), 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)
}