diff --git a/pkg/config/config.go b/pkg/config/config.go index 90d0d36..fd59bba 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -33,6 +33,7 @@ const ( CSRFInputName = "_csrf" // html input name SessionCookieMaxAge = 60 * 60 * 24 * 30 SessionRedisKeyFormat = "session/%s" + MaxBodySize = 1024 * 1024 * 8 // max upload file (e.g., 8 MB gifs) MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB ) diff --git a/pkg/controller/photo/edit_delete.go b/pkg/controller/photo/edit_delete.go index 8893f45..f0d9baf 100644 --- a/pkg/controller/photo/edit_delete.go +++ b/pkg/controller/photo/edit_delete.go @@ -3,6 +3,7 @@ package photo import ( "fmt" "net/http" + "path/filepath" "strconv" "code.nonshy.com/nonshy/website/pkg/log" @@ -67,6 +68,13 @@ func Edit() http.HandlerFunc { photo.Gallery = isGallery photo.Visibility = visibility + // Can not use a GIF as profile pic. + if setProfilePic && filepath.Ext(photo.Filename) == ".gif" { + session.FlashError(w, r, "You can not use a GIF as your profile picture.") + templates.Redirect(w, "/") + return + } + // Are we cropping ourselves a new profile pic? log.Error("Profile pic? %+v and crop is: %+v", setProfilePic, crop) if setProfilePic && crop != nil && len(crop) >= 4 { @@ -82,6 +90,8 @@ func Edit() http.HandlerFunc { photo.CroppedFilename = cropFilename log.Warn("HERE WE SET (%s) ON PHOTO (%+v)", cropFilename, photo) } + } else { + setProfilePic = false } log.Error("SAVING PHOTO: %+v", photo) diff --git a/pkg/controller/photo/upload.go b/pkg/controller/photo/upload.go index 5acdffb..31a76fc 100644 --- a/pkg/controller/photo/upload.go +++ b/pkg/controller/photo/upload.go @@ -94,6 +94,13 @@ func Upload() http.HandlerFunc { return } + // GIF can not be uploaded for a profile picture. + if filepath.Ext(header.Filename) == ".gif" && vars["Intent"] == "profile_pic" { + session.FlashError(w, r, "GIF images are not acceptable for your profile picture.") + templates.Redirect(w, r.URL.Path) + return + } + // Read the file contents. log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename) var buf bytes.Buffer diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go index 398e91d..5849a5d 100644 --- a/pkg/middleware/csrf.go +++ b/pkg/middleware/csrf.go @@ -26,7 +26,19 @@ func CSRF(handler http.Handler) http.Handler { // If we are running a POST request, validate the CSRF form value. if r.Method != http.MethodGet { - r.ParseMultipartForm(config.MultipartMaxMemory) + r.Body = http.MaxBytesReader(w, r.Body, config.MaxBodySize) + err := r.ParseMultipartForm(config.MultipartMaxMemory) + if err != nil { + log.Error("ParseMultipartForm: %s", err) + templates.MakeErrorPage( + "Request Size Too Large", + "You have uploaded too big of a file! Go back and try something else.", + http.StatusNotAcceptable, + )(w, r.WithContext(ctx)) + return + } + + // Check for the CSRF token check := r.FormValue(config.CSRFInputName) if check != token { log.Error("CSRF mismatch! %s <> %s", check, token) diff --git a/pkg/photo/gifv.go b/pkg/photo/gifv.go new file mode 100644 index 0000000..623ad50 --- /dev/null +++ b/pkg/photo/gifv.go @@ -0,0 +1,93 @@ +package photo + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "os/exec" + "strings" + + "code.nonshy.com/nonshy/website/pkg/log" +) + +// GifToMP4 converts the image into an mp4 gifv. Requires the `ffmpeg` program to be installed, or returns an error. +func GifToMP4(filename string, cfg *UploadConfig) error { + var ( + gifSize int64 + mp4Size int64 + ) + + // Write GIF to temp file + fh, err := os.CreateTemp("", "nonshy-*.gif") + if err != nil { + return err + } + defer os.Remove(fh.Name()) + + if n, err := fh.Write(cfg.Data); err != nil { + return err + } else { + gifSize = int64(n) + log.Debug("GifToMP4: written %d bytes to %s", gifSize, fh.Name()) + } + + // Prepare an mp4 tempfile to write to + var mp4 = strings.TrimSuffix(fh.Name(), ".gif") + ".mp4" + + // Run ffmpeg + command := []string{ + "ffmpeg", + "-i", fh.Name(), // .gif name + "-movflags", "faststart", + "-pix_fmt", "yuv420p", + "-vf", `scale=trunc(iw/2)*2:trunc(ih/2)*2`, + mp4, // .mp4 name + } + log.Debug("GifToMP4: Run command: %s", command) + cmd := exec.Command(command[0], command[1:]...) + if stdoutErr, err := cmd.CombinedOutput(); err != nil { + log.Error("ffmpeg failed:\n%s", stdoutErr) + + return fmt.Errorf("GIF conversion didn't work (ffmpeg might not be installed): %s", err) + } + + // Make sure the output file isn't empty. + if stat, err := os.Stat(mp4); !os.IsNotExist(err) { + mp4Size = stat.Size() + log.Debug("GifToMP4: stats of generated file %s: %d bytes", mp4, mp4Size) + if stat.Size() == 0 { + return errors.New("GIF conversion failed: output mp4 file was empty") + } + } + + // Place the .mp4 (not .gif) in the static/photos/ folder + if !strings.HasSuffix(filename, ".mp4") { + filename = strings.TrimSuffix(filename, ".gif") + ".mp4" + } + if path, err := EnsurePath(filename); err == nil { + // Copy the mp4 tempfile into the right place + srcFile, err := ioutil.ReadFile(mp4) + if err != nil { + return err + } + + destFile, err := os.Create(path) + if err != nil { + return err + } + defer destFile.Close() + + w, err := destFile.Write(srcFile) + + log.Debug("GifToMP4: Copy tempfile %s => %s; w=%d err=%s", mp4, path, w, err) + } + + log.Info("GifToMP4: converted GIF (%d bytes, %f MB) to MP4 (%d bytes, %f MB) for a %f%% savings", + gifSize, float64(gifSize)/1024/1024, + mp4Size, float64(mp4Size)/1024/1024, + float64(gifSize)/float64(mp4Size), + ) + + return nil +} diff --git a/pkg/photo/upload.go b/pkg/photo/upload.go index 09a6e8f..e2f4d7d 100644 --- a/pkg/photo/upload.go +++ b/pkg/photo/upload.go @@ -45,6 +45,8 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) { extension = ".jpg" case ".png": extension = ".png" + case ".gif": + extension = ".mp4" default: return "", "", errors.New("unsupported image extension, must be jpg or png") } @@ -93,7 +95,7 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) { scaledImg := Scale(origImage, image.Rect(0, 0, width, height), draw.ApproxBiLinear) // Write the image to disk. - if err := ToDisk(filename, extension, scaledImg); err != nil { + if err := ToDisk(filename, extension, scaledImg, &cfg); err != nil { return "", "", err } @@ -111,7 +113,7 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) { // Write that to disk, too. log.Debug("Writing cropped image to disk: %s", cropFilename) - if err := ToDisk(cropFilename, extension, croppedImg); err != nil { + if err := ToDisk(cropFilename, extension, croppedImg, &cfg); err != nil { return filename, "", err } @@ -181,7 +183,7 @@ func ReCrop(filename string, x, y, w, h int) (string, error) { croppedImg := Crop(img, x, y, w, h) // Write it. - err = ToDisk(cropFilename, ext, croppedImg) + err = ToDisk(cropFilename, ext, croppedImg, nil) return cropFilename, err } @@ -208,7 +210,20 @@ func ParseCropCoords(coords string) []int { // ToDisk commits a photo image to disk in the right file format. // // Filename is like NewFilename() and it goes to e.g. "./web/static/photos/" -func ToDisk(filename string, extension string, img image.Image) error { +// +// Encoding rules: +// - JPEG and PNG uploads are saved as JPEG +// - GIF uploads are transmuted to MP4 +func ToDisk(filename string, extension string, img image.Image, cfg *UploadConfig) error { + // GIF path handled specially (note: it will come in with extension=".mp4") + if extension == ".gif" || extension == ".mp4" { + // Requires an upload config (ReCrop not supported) + if cfg == nil { + return errors.New("can't update your GIF after original upload") + } + return GifToMP4(filename, cfg) + } + if path, err := EnsurePath(filename); err == nil { fh, err := os.Create(path) if err != nil { @@ -217,12 +232,10 @@ func ToDisk(filename string, extension string, img image.Image) error { defer fh.Close() switch extension { - case ".jpg": + case ".jpg", ".png": jpeg.Encode(fh, img, &jpeg.Options{ Quality: config.JpegQuality, }) - case ".png": - png.Encode(fh, img) } } else { return fmt.Errorf("couldn't EnsurePath: %s", err) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index 76d4b35..fc1e8ce 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -60,6 +60,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap { "UrlEncode": UrlEncode, "QueryPlus": QueryPlus(r), "SimplePager": SimplePager(r), + "HasSuffix": strings.HasSuffix, } } diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html index e85661c..1d793c1 100644 --- a/web/templates/forum/thread.html +++ b/web/templates/forum/thread.html @@ -213,9 +213,16 @@ photo expired on {{.ExpiredAt.Format "2006-01-02"}} {{else}} + + {{if HasSuffix .Filename ".mp4"}} + + {{else}}
+ {{end}} {{end}} {{end}} {{end}} diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index 0283018..7b6466f 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -389,10 +389,15 @@ {{end}} -
-
- -
+
+ + {{if HasSuffix .Filename ".mp4"}} + + {{else}} + + {{end}}
@@ -490,14 +495,19 @@ {{end}} -
-
- - - -
+
+ + {{if HasSuffix .Filename ".mp4"}} + + {{else}} + + + + {{end}}
{{if .Caption}} diff --git a/web/templates/photo/upload.html b/web/templates/photo/upload.html index d70af99..15d6c06 100644 --- a/web/templates/photo/upload.html +++ b/web/templates/photo/upload.html @@ -129,7 +129,7 @@ @@ -175,15 +175,24 @@ {{else}}
+ {{if HasSuffix .EditPhoto.Filename ".mp4"}} + + {{else}} + {{end}}
+ + {{if not (HasSuffix .EditPhoto.Filename ".mp4")}}
+ {{end}}