package attach import ( "bytes" "context" "encoding/base64" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "log/slog" "path" "regexp" "strings" "github.com/disintegration/imaging" "gocloud.dev/blob" "gocloud.dev/gcerrors" // The webp decoder lets image.DecodeConfig and File.Thumb read .webp // originals. imaging cannot encode webp, so a webp thumbnail holds JPEG // bytes under the original's .webp name (see defaultEncodeImage). _ "golang.org/x/image/webp" ) const ( maxThumbEdge = 4096 maxThumbSourceBytes = 32 << 20 maxThumbSourcePixels = 4096 * 4096 ) // thumbToken is the alphabet allowed for the mode and extension segments of a // thumb filename. Both are interpolated into a blob key, which fileblob maps // to a filesystem path, so separators and dots must never reach it. var thumbToken = regexp.MustCompile(`^[a-z0-9]+$`) // ThumbFilename is Winter File::getThumbFilename: thumb______.. // A mode or ext outside [a-z0-9]+ is coerced to "auto" / "jpg" so the result // is always a single safe path element; File.Thumb rejects such input instead. func ThumbFilename(id uint, w, h int, offsetX, offsetY int, mode, ext string) string { if !thumbToken.MatchString(mode) { mode = "auto" } if !thumbToken.MatchString(ext) { ext = "jpg" } return fmt.Sprintf("thumb_%d_%d_%d_%d_%d_%s.%s", id, w, h, offsetX, offsetY, mode, ext) } // PartitionDirectory is Winter File::getPartitionDirectory: first 9 chars of // disk_name split into 3 groups of 3, joined by '/', with a trailing slash. func PartitionDirectory(diskName string) string { var groups []string for i := 0; i < len(diskName) && len(groups) < 3; i += 3 { end := i + 3 if end > len(diskName) { end = len(diskName) } groups = append(groups, diskName[i:end]) } return strings.Join(groups, "/") + "/" } // BlobKey is the Winter on-disk key for an original file: partition + disk_name. func BlobKey(diskName string) string { return PartitionDirectory(diskName) + diskName } func fileExt(diskName string) string { ext := strings.TrimPrefix(path.Ext(diskName), ".") if ext == "" { return "jpg" } return strings.ToLower(ext) } // PublicURL returns the public URL of a blob key: storage.uploads. // public_path_prefix and the key joined by exactly one slash. With the // WinterCMS layout (bucket rooted at storage/app/uploads/public, prefix // /storage/app/uploads/public) it is Winter's File::getPath() path. func PublicURL(key string) string { prefix := strings.TrimRight(PublicPathPrefix(), "/") key = strings.TrimLeft(key, "/") if prefix == "" { return "/" + key } return prefix + "/" + key } // URL returns the public URL of the original file, Winter's File::getPath(): // PublicURL of BlobKey(DiskName). func (f *File) URL() string { if f == nil { return "" } return PublicURL(BlobKey(f.DiskName)) } func defaultResizeImage(src image.Image, w, h int, mode string) image.Image { switch strings.ToLower(mode) { case "crop": return imaging.Fill(src, w, h, imaging.Center, imaging.Lanczos) case "exact": return imaging.Resize(src, w, h, imaging.Lanczos) default: return imaging.Fit(src, w, h, imaging.Lanczos) } } var resizeImage = defaultResizeImage func defaultEncodeImage(w io.Writer, img image.Image, ext string) error { format := imaging.JPEG switch strings.ToLower(ext) { case "png": format = imaging.PNG case "gif": format = imaging.GIF } return imaging.Encode(w, img, format) } var encodeImage = defaultEncodeImage // Thumb returns the public URL of a lazily generated thumbnail. The second // call for the same dimensions hits the existing blob and does not resize. // It is PublicURL of ThumbKey; see ThumbKey for the generation rules. func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error) { key, err := f.ThumbKey(ctx, bucket, w, h, mode) if err != nil { return "", err } return PublicURL(key), nil } // ThumbKey returns the blob key of a lazily generated thumbnail, generating // it on first use, so a caller that must not emit a public URL (a protected // file) can stream the thumbnail itself. The second call for the same // dimensions hits the existing blob and does not resize. // // As WinterCMS's File::makeThumb does, an original that is missing from // the bucket, does not decode, or declares more than 4096 by 4096 pixels // gets WinterCMS's broken-image picture (BrokenImagePNG) stored as its // thumbnail, and the failure is logged at warn level instead of returned: // one unusable upload never fails the listings that show it. An invalid // mode or size, a storage error and an encode failure are still errors. func (f *File) ThumbKey(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error) { if f == nil { return "", fmt.Errorf("attach: file is nil") } if bucket == nil { return "", fmt.Errorf("attach: bucket is nil") } if mode == "" { mode = "auto" } mode = strings.ToLower(mode) if !thumbToken.MatchString(mode) { return "", fmt.Errorf("attach: invalid thumb mode %q", mode) } if w <= 0 || h <= 0 || w > maxThumbEdge || h > maxThumbEdge { return "", fmt.Errorf("attach: thumb size %dx%d is out of range", w, h) } ext := fileExt(f.DiskName) if !thumbToken.MatchString(ext) { return "", fmt.Errorf("attach: invalid thumb extension %q", ext) } thumbName := ThumbFilename(f.ID, w, h, 0, 0, mode, ext) part := PartitionDirectory(f.DiskName) thumbKey := part + thumbName exists, err := bucket.Exists(ctx, thumbKey) if err != nil { return "", fmt.Errorf("attach: thumb exists: %w", err) } if exists { return thumbKey, nil } origKey := part + f.DiskName r, err := bucket.NewReader(ctx, origKey, nil) if gcerrors.Code(err) == gcerrors.NotFound { return brokenThumb(ctx, bucket, f, thumbKey, err) } if err != nil { return "", fmt.Errorf("attach: read original: %w", err) } raw, err := io.ReadAll(io.LimitReader(r, maxThumbSourceBytes)) closeErr := r.Close() if err != nil { return "", fmt.Errorf("attach: read original: %w", err) } if closeErr != nil { return "", closeErr } // Check the dimensions from the header before decoding the pixels, so // a small file that declares a huge image is refused without // allocating it. cfg, _, err := image.DecodeConfig(bytes.NewReader(raw)) if err != nil { return brokenThumb(ctx, bucket, f, thumbKey, fmt.Errorf("decode original: %w", err)) } if int64(cfg.Width)*int64(cfg.Height) > maxThumbSourcePixels { return brokenThumb(ctx, bucket, f, thumbKey, errOriginalTooLarge) } src, _, err := image.Decode(bytes.NewReader(raw)) if err != nil { return brokenThumb(ctx, bucket, f, thumbKey, fmt.Errorf("decode original: %w", err)) } bounds := src.Bounds() if int64(bounds.Dx())*int64(bounds.Dy()) > maxThumbSourcePixels { return brokenThumb(ctx, bucket, f, thumbKey, errOriginalTooLarge) } resized := resizeImage(src, w, h, mode) contentType := "image/jpeg" switch ext { case "png": contentType = "image/png" case "gif": contentType = "image/gif" } wr, err := bucket.NewWriter(ctx, thumbKey, &blob.WriterOptions{ContentType: contentType}) if err != nil { return "", fmt.Errorf("attach: thumb writer: %w", err) } encErr := encodeImage(wr, resized, ext) closeErr = wr.Close() if encErr != nil || closeErr != nil { _ = deleteKey(ctx, bucket, thumbKey) if encErr != nil { return "", encErr } return "", closeErr } return thumbKey, nil } var errOriginalTooLarge = errors.New("original image is too large") // brokenImageBase64 is WinterCMS's Database\Attach\BrokenImage picture, a // 200x200 PNG, byte for byte. const brokenImageBase64 = "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" // BrokenImagePNG is the PNG Thumb stores as the thumbnail of an unusable // original: WinterCMS's BrokenImage picture. var BrokenImagePNG = func() []byte { b, err := base64.StdEncoding.DecodeString(brokenImageBase64) if err != nil { panic(err) } return b }() // brokenThumb is the catch branch of WinterCMS's File::makeThumb: log the // reason, store BrokenImagePNG under the thumbnail key and return that key. func brokenThumb(ctx context.Context, bucket *blob.Bucket, f *File, thumbKey string, reason error) (string, error) { slog.Default().WarnContext(ctx, "attach: thumbnail original is unusable, storing the broken-image picture", slog.Uint64("file_id", uint64(f.ID)), slog.String("error", reason.Error())) if err := bucket.WriteAll(ctx, thumbKey, BrokenImagePNG, &blob.WriterOptions{ContentType: "image/png"}); err != nil { return "", fmt.Errorf("attach: broken-image thumb: %w", err) } return thumbKey, nil }