More image type support for thumbnails (#814)

This commit is contained in:
Matt
2025-06-26 10:19:34 -04:00
committed by GitHub
parent 09cccc63a7
commit 4861a8537f
23 changed files with 408 additions and 25 deletions

View File

@@ -31,6 +31,8 @@ type Attachment struct {
Title string `json:"title,omitempty"`
// Path holds the value of the "path" field.
Path string `json:"path,omitempty"`
// MimeType holds the value of the "mime_type" field.
MimeType string `json:"mime_type,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the AttachmentQuery when eager-loading is set.
Edges AttachmentEdges `json:"edges"`
@@ -79,7 +81,7 @@ func (*Attachment) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case attachment.FieldPrimary:
values[i] = new(sql.NullBool)
case attachment.FieldType, attachment.FieldTitle, attachment.FieldPath:
case attachment.FieldType, attachment.FieldTitle, attachment.FieldPath, attachment.FieldMimeType:
values[i] = new(sql.NullString)
case attachment.FieldCreatedAt, attachment.FieldUpdatedAt:
values[i] = new(sql.NullTime)
@@ -146,6 +148,12 @@ func (a *Attachment) assignValues(columns []string, values []any) error {
} else if value.Valid {
a.Path = value.String
}
case attachment.FieldMimeType:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field mime_type", values[i])
} else if value.Valid {
a.MimeType = value.String
}
case attachment.ForeignKeys[0]:
if value, ok := values[i].(*sql.NullScanner); !ok {
return fmt.Errorf("unexpected type %T for field attachment_thumbnail", values[i])
@@ -223,6 +231,9 @@ func (a *Attachment) String() string {
builder.WriteString(", ")
builder.WriteString("path=")
builder.WriteString(a.Path)
builder.WriteString(", ")
builder.WriteString("mime_type=")
builder.WriteString(a.MimeType)
builder.WriteByte(')')
return builder.String()
}

View File

@@ -28,6 +28,8 @@ const (
FieldTitle = "title"
// FieldPath holds the string denoting the path field in the database.
FieldPath = "path"
// FieldMimeType holds the string denoting the mime_type field in the database.
FieldMimeType = "mime_type"
// EdgeItem holds the string denoting the item edge name in mutations.
EdgeItem = "item"
// EdgeThumbnail holds the string denoting the thumbnail edge name in mutations.
@@ -56,6 +58,7 @@ var Columns = []string{
FieldPrimary,
FieldTitle,
FieldPath,
FieldMimeType,
}
// ForeignKeys holds the SQL foreign-keys that are owned by the "attachments"
@@ -93,6 +96,8 @@ var (
DefaultTitle string
// DefaultPath holds the default value on creation for the "path" field.
DefaultPath string
// DefaultMimeType holds the default value on creation for the "mime_type" field.
DefaultMimeType string
// DefaultID holds the default value on creation for the "id" field.
DefaultID func() uuid.UUID
)
@@ -165,6 +170,11 @@ func ByPath(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPath, opts...).ToFunc()
}
// ByMimeType orders the results by the mime_type field.
func ByMimeType(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMimeType, opts...).ToFunc()
}
// ByItemField orders the results by item field.
func ByItemField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {

View File

@@ -81,6 +81,11 @@ func Path(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldEQ(FieldPath, v))
}
// MimeType applies equality check predicate on the "mime_type" field. It's identical to MimeTypeEQ.
func MimeType(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldEQ(FieldMimeType, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Attachment {
return predicate.Attachment(sql.FieldEQ(FieldCreatedAt, v))
@@ -321,6 +326,71 @@ func PathContainsFold(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldContainsFold(FieldPath, v))
}
// MimeTypeEQ applies the EQ predicate on the "mime_type" field.
func MimeTypeEQ(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldEQ(FieldMimeType, v))
}
// MimeTypeNEQ applies the NEQ predicate on the "mime_type" field.
func MimeTypeNEQ(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldNEQ(FieldMimeType, v))
}
// MimeTypeIn applies the In predicate on the "mime_type" field.
func MimeTypeIn(vs ...string) predicate.Attachment {
return predicate.Attachment(sql.FieldIn(FieldMimeType, vs...))
}
// MimeTypeNotIn applies the NotIn predicate on the "mime_type" field.
func MimeTypeNotIn(vs ...string) predicate.Attachment {
return predicate.Attachment(sql.FieldNotIn(FieldMimeType, vs...))
}
// MimeTypeGT applies the GT predicate on the "mime_type" field.
func MimeTypeGT(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldGT(FieldMimeType, v))
}
// MimeTypeGTE applies the GTE predicate on the "mime_type" field.
func MimeTypeGTE(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldGTE(FieldMimeType, v))
}
// MimeTypeLT applies the LT predicate on the "mime_type" field.
func MimeTypeLT(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldLT(FieldMimeType, v))
}
// MimeTypeLTE applies the LTE predicate on the "mime_type" field.
func MimeTypeLTE(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldLTE(FieldMimeType, v))
}
// MimeTypeContains applies the Contains predicate on the "mime_type" field.
func MimeTypeContains(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldContains(FieldMimeType, v))
}
// MimeTypeHasPrefix applies the HasPrefix predicate on the "mime_type" field.
func MimeTypeHasPrefix(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldHasPrefix(FieldMimeType, v))
}
// MimeTypeHasSuffix applies the HasSuffix predicate on the "mime_type" field.
func MimeTypeHasSuffix(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldHasSuffix(FieldMimeType, v))
}
// MimeTypeEqualFold applies the EqualFold predicate on the "mime_type" field.
func MimeTypeEqualFold(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldEqualFold(FieldMimeType, v))
}
// MimeTypeContainsFold applies the ContainsFold predicate on the "mime_type" field.
func MimeTypeContainsFold(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldContainsFold(FieldMimeType, v))
}
// HasItem applies the HasEdge predicate on the "item" edge.
func HasItem() predicate.Attachment {
return predicate.Attachment(func(s *sql.Selector) {

View File

@@ -106,6 +106,20 @@ func (ac *AttachmentCreate) SetNillablePath(s *string) *AttachmentCreate {
return ac
}
// SetMimeType sets the "mime_type" field.
func (ac *AttachmentCreate) SetMimeType(s string) *AttachmentCreate {
ac.mutation.SetMimeType(s)
return ac
}
// SetNillableMimeType sets the "mime_type" field if the given value is not nil.
func (ac *AttachmentCreate) SetNillableMimeType(s *string) *AttachmentCreate {
if s != nil {
ac.SetMimeType(*s)
}
return ac
}
// SetID sets the "id" field.
func (ac *AttachmentCreate) SetID(u uuid.UUID) *AttachmentCreate {
ac.mutation.SetID(u)
@@ -217,6 +231,10 @@ func (ac *AttachmentCreate) defaults() {
v := attachment.DefaultPath
ac.mutation.SetPath(v)
}
if _, ok := ac.mutation.MimeType(); !ok {
v := attachment.DefaultMimeType
ac.mutation.SetMimeType(v)
}
if _, ok := ac.mutation.ID(); !ok {
v := attachment.DefaultID()
ac.mutation.SetID(v)
@@ -248,6 +266,9 @@ func (ac *AttachmentCreate) check() error {
if _, ok := ac.mutation.Path(); !ok {
return &ValidationError{Name: "path", err: errors.New(`ent: missing required field "Attachment.path"`)}
}
if _, ok := ac.mutation.MimeType(); !ok {
return &ValidationError{Name: "mime_type", err: errors.New(`ent: missing required field "Attachment.mime_type"`)}
}
return nil
}
@@ -307,6 +328,10 @@ func (ac *AttachmentCreate) createSpec() (*Attachment, *sqlgraph.CreateSpec) {
_spec.SetField(attachment.FieldPath, field.TypeString, value)
_node.Path = value
}
if value, ok := ac.mutation.MimeType(); ok {
_spec.SetField(attachment.FieldMimeType, field.TypeString, value)
_node.MimeType = value
}
if nodes := ac.mutation.ItemIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,

View File

@@ -92,6 +92,20 @@ func (au *AttachmentUpdate) SetNillablePath(s *string) *AttachmentUpdate {
return au
}
// SetMimeType sets the "mime_type" field.
func (au *AttachmentUpdate) SetMimeType(s string) *AttachmentUpdate {
au.mutation.SetMimeType(s)
return au
}
// SetNillableMimeType sets the "mime_type" field if the given value is not nil.
func (au *AttachmentUpdate) SetNillableMimeType(s *string) *AttachmentUpdate {
if s != nil {
au.SetMimeType(*s)
}
return au
}
// SetItemID sets the "item" edge to the Item entity by ID.
func (au *AttachmentUpdate) SetItemID(id uuid.UUID) *AttachmentUpdate {
au.mutation.SetItemID(id)
@@ -220,6 +234,9 @@ func (au *AttachmentUpdate) sqlSave(ctx context.Context) (n int, err error) {
if value, ok := au.mutation.Path(); ok {
_spec.SetField(attachment.FieldPath, field.TypeString, value)
}
if value, ok := au.mutation.MimeType(); ok {
_spec.SetField(attachment.FieldMimeType, field.TypeString, value)
}
if au.mutation.ItemCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -360,6 +377,20 @@ func (auo *AttachmentUpdateOne) SetNillablePath(s *string) *AttachmentUpdateOne
return auo
}
// SetMimeType sets the "mime_type" field.
func (auo *AttachmentUpdateOne) SetMimeType(s string) *AttachmentUpdateOne {
auo.mutation.SetMimeType(s)
return auo
}
// SetNillableMimeType sets the "mime_type" field if the given value is not nil.
func (auo *AttachmentUpdateOne) SetNillableMimeType(s *string) *AttachmentUpdateOne {
if s != nil {
auo.SetMimeType(*s)
}
return auo
}
// SetItemID sets the "item" edge to the Item entity by ID.
func (auo *AttachmentUpdateOne) SetItemID(id uuid.UUID) *AttachmentUpdateOne {
auo.mutation.SetItemID(id)
@@ -518,6 +549,9 @@ func (auo *AttachmentUpdateOne) sqlSave(ctx context.Context) (_node *Attachment,
if value, ok := auo.mutation.Path(); ok {
_spec.SetField(attachment.FieldPath, field.TypeString, value)
}
if value, ok := auo.mutation.MimeType(); ok {
_spec.SetField(attachment.FieldMimeType, field.TypeString, value)
}
if auo.mutation.ItemCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,

View File

@@ -17,6 +17,7 @@ var (
{Name: "primary", Type: field.TypeBool, Default: false},
{Name: "title", Type: field.TypeString, Default: ""},
{Name: "path", Type: field.TypeString, Default: ""},
{Name: "mime_type", Type: field.TypeString, Default: "application/octet-stream"},
{Name: "attachment_thumbnail", Type: field.TypeUUID, Unique: true, Nullable: true},
{Name: "item_attachments", Type: field.TypeUUID, Nullable: true},
}
@@ -28,13 +29,13 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "attachments_attachments_thumbnail",
Columns: []*schema.Column{AttachmentsColumns[7]},
Columns: []*schema.Column{AttachmentsColumns[8]},
RefColumns: []*schema.Column{AttachmentsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "attachments_items_attachments",
Columns: []*schema.Column{AttachmentsColumns[8]},
Columns: []*schema.Column{AttachmentsColumns[9]},
RefColumns: []*schema.Column{ItemsColumns[0]},
OnDelete: schema.Cascade,
},

View File

@@ -62,6 +62,7 @@ type AttachmentMutation struct {
primary *bool
title *string
_path *string
mime_type *string
clearedFields map[string]struct{}
item *uuid.UUID
cleareditem bool
@@ -392,6 +393,42 @@ func (m *AttachmentMutation) ResetPath() {
m._path = nil
}
// SetMimeType sets the "mime_type" field.
func (m *AttachmentMutation) SetMimeType(s string) {
m.mime_type = &s
}
// MimeType returns the value of the "mime_type" field in the mutation.
func (m *AttachmentMutation) MimeType() (r string, exists bool) {
v := m.mime_type
if v == nil {
return
}
return *v, true
}
// OldMimeType returns the old "mime_type" field's value of the Attachment entity.
// If the Attachment object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *AttachmentMutation) OldMimeType(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldMimeType is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldMimeType requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldMimeType: %w", err)
}
return oldValue.MimeType, nil
}
// ResetMimeType resets all changes to the "mime_type" field.
func (m *AttachmentMutation) ResetMimeType() {
m.mime_type = nil
}
// SetItemID sets the "item" edge to the Item entity by id.
func (m *AttachmentMutation) SetItemID(id uuid.UUID) {
m.item = &id
@@ -504,7 +541,7 @@ func (m *AttachmentMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *AttachmentMutation) Fields() []string {
fields := make([]string, 0, 6)
fields := make([]string, 0, 7)
if m.created_at != nil {
fields = append(fields, attachment.FieldCreatedAt)
}
@@ -523,6 +560,9 @@ func (m *AttachmentMutation) Fields() []string {
if m._path != nil {
fields = append(fields, attachment.FieldPath)
}
if m.mime_type != nil {
fields = append(fields, attachment.FieldMimeType)
}
return fields
}
@@ -543,6 +583,8 @@ func (m *AttachmentMutation) Field(name string) (ent.Value, bool) {
return m.Title()
case attachment.FieldPath:
return m.Path()
case attachment.FieldMimeType:
return m.MimeType()
}
return nil, false
}
@@ -564,6 +606,8 @@ func (m *AttachmentMutation) OldField(ctx context.Context, name string) (ent.Val
return m.OldTitle(ctx)
case attachment.FieldPath:
return m.OldPath(ctx)
case attachment.FieldMimeType:
return m.OldMimeType(ctx)
}
return nil, fmt.Errorf("unknown Attachment field %s", name)
}
@@ -615,6 +659,13 @@ func (m *AttachmentMutation) SetField(name string, value ent.Value) error {
}
m.SetPath(v)
return nil
case attachment.FieldMimeType:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetMimeType(v)
return nil
}
return fmt.Errorf("unknown Attachment field %s", name)
}
@@ -682,6 +733,9 @@ func (m *AttachmentMutation) ResetField(name string) error {
case attachment.FieldPath:
m.ResetPath()
return nil
case attachment.FieldMimeType:
m.ResetMimeType()
return nil
}
return fmt.Errorf("unknown Attachment field %s", name)
}

View File

@@ -51,6 +51,10 @@ func init() {
attachmentDescPath := attachmentFields[3].Descriptor()
// attachment.DefaultPath holds the default value on creation for the path field.
attachment.DefaultPath = attachmentDescPath.Default.(string)
// attachmentDescMimeType is the schema descriptor for mime_type field.
attachmentDescMimeType := attachmentFields[4].Descriptor()
// attachment.DefaultMimeType holds the default value on creation for the mime_type field.
attachment.DefaultMimeType = attachmentDescMimeType.Default.(string)
// attachmentDescID is the schema descriptor for id field.
attachmentDescID := attachmentMixinFields0[0].Descriptor()
// attachment.DefaultID holds the default value on creation for the id field.

View File

@@ -25,6 +25,7 @@ func (Attachment) Fields() []ent.Field {
field.Bool("primary").Default(false),
field.String("title").Default(""),
field.String("path").Default(""),
field.String("mime_type").Default("application/octet-stream"),
}
}

View File

@@ -0,0 +1,3 @@
-- +goose Up
ALTER TABLE public.attachments ADD COLUMN mime_type VARCHAR DEFAULT 'application/octet-stream';

View File

@@ -0,0 +1,3 @@
-- +goose Up
ALTER TABLE attachments ADD COLUMN mime_type TEXT DEFAULT 'application/octet-stream';

View File

@@ -12,6 +12,8 @@ import (
"github.com/zeebo/blake3"
"github.com/gen2brain/avif"
"github.com/gen2brain/heic"
"github.com/gen2brain/jpegxl"
"github.com/gen2brain/webp"
"golang.org/x/image/draw"
"image"
@@ -62,6 +64,7 @@ type (
Primary bool `json:"primary"`
Path string `json:"path"`
Title string `json:"title"`
MimeType string `json:"mimeType,omitempty"`
Thumbnail *ent.Attachment `json:"thumbnail,omitempty"`
}
@@ -87,6 +90,8 @@ func ToItemAttachment(attachment *ent.Attachment) ItemAttachment {
Primary: attachment.Primary,
Path: attachment.Path,
Title: attachment.Title,
MimeType: attachment.MimeType,
Thumbnail: attachment.QueryThumbnail().FirstX(context.Background()),
}
}
@@ -193,6 +198,17 @@ func (r *AttachmentRepo) Create(ctx context.Context, itemID uuid.UUID, doc ItemC
return nil, err
}
limitedReader := io.LimitReader(doc.Content, 1024*128)
file, err := io.ReadAll(limitedReader)
if err != nil {
log.Err(err).Msg("failed to read file content")
err = tx.Rollback()
if err != nil {
return nil, err
}
return nil, err
}
bldr = bldr.SetMimeType(http.DetectContentType(file[:min(512, len(file))]))
bldr = bldr.SetPath(path)
attachmentDb, err := bldr.Save(ctx)
@@ -416,6 +432,14 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen
log.Debug().Msg("detecting content type of original file")
contentType := http.DetectContentType(contentBytes[:min(512, len(contentBytes))])
if contentType == "application/octet-stream" {
if strings.HasSuffix(title, ".heic") || strings.HasSuffix(title, ".heif") {
contentType = "image/heic"
} else if strings.HasSuffix(title, ".avif") {
contentType = "image/avif"
}
}
switch {
case isImageFile(contentType):
log.Debug().Msg("creating thumbnail for image file")
@@ -532,10 +556,88 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen
}
log.Debug().Msg("setting thumbnail file path in attachment")
att.SetPath(thumbnailFile)
case contentType == "image/heic" || contentType == "image/heif":
log.Debug().Msg("creating thumbnail for heic file")
img, err := heic.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode avif image")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
dst := image.NewRGBA(image.Rect(0, 0, r.thumbnail.Width, r.thumbnail.Height))
draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil)
buf := new(bytes.Buffer)
err = webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false})
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
return err
}
contentBytes := buf.Bytes()
log.Debug().Msg("uploading thumbnail file")
thumbnailFile, err := r.UploadFile(ctx, tx.Group.GetX(ctx, groupId), ItemCreateAttachment{
Title: fmt.Sprintf("%s-thumb", title),
Content: bytes.NewReader(contentBytes),
})
if err != nil {
log.Err(err).Msg("failed to upload thumbnail file")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
log.Debug().Msg("setting thumbnail file path in attachment")
att.SetPath(thumbnailFile)
case contentType == "image/jxl":
log.Debug().Msg("creating thumbnail for jpegxl file")
img, err := jpegxl.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode avif image")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
dst := image.NewRGBA(image.Rect(0, 0, r.thumbnail.Width, r.thumbnail.Height))
draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil)
buf := new(bytes.Buffer)
err = webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false})
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
return err
}
contentBytes := buf.Bytes()
log.Debug().Msg("uploading thumbnail file")
thumbnailFile, err := r.UploadFile(ctx, tx.Group.GetX(ctx, groupId), ItemCreateAttachment{
Title: fmt.Sprintf("%s-thumb", title),
Content: bytes.NewReader(contentBytes),
})
if err != nil {
log.Err(err).Msg("failed to upload thumbnail file")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
log.Debug().Msg("setting thumbnail file path in attachment")
att.SetPath(thumbnailFile)
default:
return fmt.Errorf("file type %s is not supported for thumbnail creation or document thumnails disabled", title)
}
att.SetMimeType("image/webp")
log.Debug().Msg("saving thumbnail attachment to database")
thumbnail, err := att.Save(ctx)
if err != nil {