package classes import ( "bytes" "context" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "net/http" "path" "regexp" "strings" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/pact" "gocloud.dev/blob" ) // ImageFolder is the media folder social images are stored in. const ImageFolder = "media/seo" // ImageMaxBytes caps a social image upload (10 MiB, as the media library). const ImageMaxBytes = 10 << 20 // The phrase keys of the image refusals. const ( errImageInvalid = "golem15.seo::lang.image.invalid" errImageTooBig = "golem15.seo::lang.image.too_big" errImageURL = "golem15.seo::lang.image.url_invalid" errImageForbidden = "golem15.seo::lang.image.forbidden" ) // imageExtensions maps the sniffed image types to their file extensions. var imageExtensions = map[string]string{ "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", } var unsafeNameChars = regexp.MustCompile(`[^a-z0-9._-]+`) // safeImageName is the stored base name: lowercased, characters outside // a-z, 0-9, '.', '_' and '-' turned into '-', at most 80 characters, with // the sniffed extension. func safeImageName(filename, ext string) string { base := path.Base(strings.ReplaceAll(filename, `\`, "/")) base = strings.TrimSuffix(base, path.Ext(base)) base = strings.Trim(unsafeNameChars.ReplaceAllString(strings.ToLower(base), "-"), "-.") if base == "" { base = "image" } if len(base) > 80 { base = base[:80] } return base + "." + ext } // UploadImage is the seoimage field type's Upload (D-100, P-16): the // administrator needs golem15.seo.manage; the first bytes must sniff as a // JPEG, PNG, GIF or WebP image; the file is stored in the media bucket // under media/seo/- (a name that is safe to leave behind // when the save never happens) and its public URL is returned. func UploadImage(app func() *backpack.App, permission string) func(context.Context, pact.FormFieldUpload) (pact.FormFieldUploadResult, error) { return func(ctx context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) { if p, ok := bouncer.User(ctx); !ok || !cabana.Allows(p, []string{permission}) { return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageForbidden}} } var a *backpack.App if app != nil { a = app() } if a == nil { return pact.FormFieldUploadResult{}, errors.New("golem15.seo: image upload: application is not configured") } bucket, ok := a.Lookup[*blob.Bucket]() if !ok || bucket == nil { return pact.FormFieldUploadResult{}, errors.New("golem15.seo: image upload: media bucket is not configured") } if in.Body == nil { return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageInvalid}} } data, err := io.ReadAll(io.LimitReader(in.Body, ImageMaxBytes+1)) if err != nil { return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: image upload: read: %w", err) } if len(data) > ImageMaxBytes { return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageTooBig}} } ext, known := imageExtensions[http.DetectContentType(data)] if !known { return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageInvalid}} } random := make([]byte, 6) if _, err := rand.Read(random); err != nil { return pact.FormFieldUploadResult{}, err } key := ImageFolder + "/" + hex.EncodeToString(random) + "-" + safeImageName(in.Filename, ext) w, err := bucket.NewWriter(ctx, key, &blob.WriterOptions{IfNotExist: true, ContentType: "image/" + strings.Replace(ext, "jpg", "jpeg", 1)}) if err != nil { return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: image writer: %w", err) } if _, err := io.Copy(w, bytes.NewReader(data)); err != nil { _ = w.Close() return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: write image: %w", err) } if err := w.Close(); err != nil { return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: close image: %w", err) } return pact.FormFieldUploadResult{URL: attach.PublicURL(key)}, nil } } // ValidImageURL reports whether a stored social image value is empty, a // path or an absolute http(s) URL. func ValidImageURL(v string) bool { return v == "" || ValidURLRef(v) }