From 4861a8537fbec8c3fd1a452b8e74cf691e2ffec4 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 26 Jun 2025 10:19:34 -0400 Subject: [PATCH] More image type support for thumbnails (#814) --- .../handlers/v1/v1_ctrl_items_attachments.go | 2 +- backend/app/api/main.go | 9 +- backend/app/api/static/docs/docs.go | 7 ++ backend/app/api/static/docs/swagger.json | 7 ++ backend/app/api/static/docs/swagger.yaml | 5 + backend/go.mod | 2 + backend/go.sum | 12 +++ backend/internal/data/ent/attachment.go | 13 ++- .../data/ent/attachment/attachment.go | 10 ++ backend/internal/data/ent/attachment/where.go | 70 ++++++++++++ .../internal/data/ent/attachment_create.go | 25 +++++ .../internal/data/ent/attachment_update.go | 34 ++++++ backend/internal/data/ent/migrate/schema.go | 5 +- backend/internal/data/ent/mutation.go | 56 +++++++++- backend/internal/data/ent/runtime.go | 4 + .../internal/data/ent/schema/attachment.go | 1 + .../postgres/20250625120010_add_mime_type.sql | 3 + .../sqlite3/20250625120000_add_mime_type.sql | 3 + .../data/repo/repo_item_attachments.go | 102 ++++++++++++++++++ docs/en/api/openapi-2.0.json | 7 ++ docs/en/api/openapi-2.0.yaml | 5 + frontend/lib/api/types/data-contracts.ts | 3 + frontend/pages/item/[id]/index.vue | 48 ++++++--- 23 files changed, 408 insertions(+), 25 deletions(-) create mode 100644 backend/internal/data/migrations/postgres/20250625120010_add_mime_type.sql create mode 100644 backend/internal/data/migrations/sqlite3/20250625120000_add_mime_type.sql diff --git a/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go b/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go index b4953fff..9e303e72 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go +++ b/backend/app/api/handlers/v1/v1_ctrl_items_attachments.go @@ -82,7 +82,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc { ext := filepath.Ext(attachmentName) switch strings.ToLower(ext) { - case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".avif", ".ico": + case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".avif", ".ico", ".heic", ".jxl": attachmentType = attachment.TypePhoto.String() default: attachmentType = attachment.TypeAttachment.String() diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 98f1ab07..59bff0e3 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -309,7 +309,7 @@ func run(cfg *config.Config) error { } })) - runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error { + go runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error { pubsubString, err := utils.GenerateSubPubConn(cfg.Database.PubSubConnString, "thumbnails") if err != nil { log.Error().Err(err).Msg("failed to generate pubsub connection string") @@ -347,7 +347,6 @@ func run(cfg *config.Config) error { log.Debug().Msg("received thumbnail generation request from pubsub topic") if err != nil { log.Err(err).Msg("failed to receive message from pubsub topic") - return err } groupId, err := uuid.Parse(msg.Metadata["group_id"]) if err != nil { @@ -355,8 +354,6 @@ func run(cfg *config.Config) error { Err(err). Str("group_id", msg.Metadata["group_id"]). Msg("failed to parse group ID from message metadata") - msg.Nack() - return err } attachmentId, err := uuid.Parse(msg.Metadata["attachment_id"]) if err != nil { @@ -364,14 +361,10 @@ func run(cfg *config.Config) error { Err(err). Str("attachment_id", msg.Metadata["attachment_id"]). Msg("failed to parse attachment ID from message metadata") - msg.Nack() - return err } err = app.repos.Attachments.CreateThumbnail(ctx, groupId, attachmentId, msg.Metadata["title"], msg.Metadata["path"]) if err != nil { - msg.Nack() log.Err(err).Msg("failed to create thumbnail") - return err } msg.Ack() } diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index aaa52f11..843f8cea 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -2193,6 +2193,10 @@ const docTemplate = `{ "description": "ID of the ent.", "type": "string" }, + "mime_type": { + "description": "MimeType holds the value of the \"mime_type\" field.", + "type": "string" + }, "path": { "description": "Path holds the value of the \"path\" field.", "type": "string" @@ -3122,6 +3126,9 @@ const docTemplate = `{ "id": { "type": "string" }, + "mimeType": { + "type": "string" + }, "path": { "type": "string" }, diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 1b5e301c..52616edb 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -2191,6 +2191,10 @@ "description": "ID of the ent.", "type": "string" }, + "mime_type": { + "description": "MimeType holds the value of the \"mime_type\" field.", + "type": "string" + }, "path": { "description": "Path holds the value of the \"path\" field.", "type": "string" @@ -3120,6 +3124,9 @@ "id": { "type": "string" }, + "mimeType": { + "type": "string" + }, "path": { "type": "string" }, diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index cce02236..35cb6d8c 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -55,6 +55,9 @@ definitions: id: description: ID of the ent. type: string + mime_type: + description: MimeType holds the value of the "mime_type" field. + type: string path: description: Path holds the value of the "path" field. type: string @@ -684,6 +687,8 @@ definitions: type: string id: type: string + mimeType: + type: string path: type: string primary: diff --git a/backend/go.mod b/backend/go.mod index 207d2138..7cfcccd1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,6 +9,8 @@ require ( github.com/ardanlabs/conf/v3 v3.8.0 github.com/containrrr/shoutrrr v0.8.0 github.com/gen2brain/avif v0.4.4 + github.com/gen2brain/heic v0.4.5 + github.com/gen2brain/jpegxl v0.4.5 github.com/gen2brain/webp v0.5.5 github.com/go-chi/chi/v5 v5.2.2 github.com/go-playground/validator/v10 v10.26.0 diff --git a/backend/go.sum b/backend/go.sum index 5d7b03c4..29ecd521 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -183,6 +183,10 @@ github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3G github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gen2brain/avif v0.4.4 h1:Ga/ss7qcWWQm2bxFpnjYjhJsNfZrWs5RsyklgFjKRSE= github.com/gen2brain/avif v0.4.4/go.mod h1:/XCaJcjZraQwKVhpu9aEd9aLOssYOawLvhMBtmHVGqk= +github.com/gen2brain/heic v0.4.5 h1:Cq3hPu6wwlTJNv2t48ro3oWje54h82Q5pALeCBNgaSk= +github.com/gen2brain/heic v0.4.5/go.mod h1:ECnpqbqLu0qSje4KSNWUUDK47UPXPzl80T27GWGEL5I= +github.com/gen2brain/jpegxl v0.4.5 h1:TWpVEn5xkIfsswzkjHBArd0Cc9AE0tbjBSoa0jDsrbo= +github.com/gen2brain/jpegxl v0.4.5/go.mod h1:4kWYJ18xCEuO2vzocYdGpeqNJ990/Gjy3uLMg5TBN6I= github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= @@ -346,6 +350,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -369,6 +375,8 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olahol/melody v1.2.1 h1:xdwRkzHxf+B0w4TKbGpUSSkV516ZucQZJIWLztOWICQ= github.com/olahol/melody v1.2.1/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= @@ -409,6 +417,10 @@ github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQU github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/backend/internal/data/ent/attachment.go b/backend/internal/data/ent/attachment.go index ab47ee35..d947086f 100644 --- a/backend/internal/data/ent/attachment.go +++ b/backend/internal/data/ent/attachment.go @@ -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() } diff --git a/backend/internal/data/ent/attachment/attachment.go b/backend/internal/data/ent/attachment/attachment.go index 419f6eab..c24e3230 100644 --- a/backend/internal/data/ent/attachment/attachment.go +++ b/backend/internal/data/ent/attachment/attachment.go @@ -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) { diff --git a/backend/internal/data/ent/attachment/where.go b/backend/internal/data/ent/attachment/where.go index 75e17790..d6ab48a0 100644 --- a/backend/internal/data/ent/attachment/where.go +++ b/backend/internal/data/ent/attachment/where.go @@ -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) { diff --git a/backend/internal/data/ent/attachment_create.go b/backend/internal/data/ent/attachment_create.go index d5c9d69c..019c8363 100644 --- a/backend/internal/data/ent/attachment_create.go +++ b/backend/internal/data/ent/attachment_create.go @@ -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, diff --git a/backend/internal/data/ent/attachment_update.go b/backend/internal/data/ent/attachment_update.go index 32b73894..15eb4be7 100644 --- a/backend/internal/data/ent/attachment_update.go +++ b/backend/internal/data/ent/attachment_update.go @@ -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, diff --git a/backend/internal/data/ent/migrate/schema.go b/backend/internal/data/ent/migrate/schema.go index 15a35a2d..55a2d896 100644 --- a/backend/internal/data/ent/migrate/schema.go +++ b/backend/internal/data/ent/migrate/schema.go @@ -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, }, diff --git a/backend/internal/data/ent/mutation.go b/backend/internal/data/ent/mutation.go index a4985f26..7f07202b 100644 --- a/backend/internal/data/ent/mutation.go +++ b/backend/internal/data/ent/mutation.go @@ -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) } diff --git a/backend/internal/data/ent/runtime.go b/backend/internal/data/ent/runtime.go index 1f1c40c1..4d73e455 100644 --- a/backend/internal/data/ent/runtime.go +++ b/backend/internal/data/ent/runtime.go @@ -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. diff --git a/backend/internal/data/ent/schema/attachment.go b/backend/internal/data/ent/schema/attachment.go index 115a87a9..bbb3897d 100644 --- a/backend/internal/data/ent/schema/attachment.go +++ b/backend/internal/data/ent/schema/attachment.go @@ -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"), } } diff --git a/backend/internal/data/migrations/postgres/20250625120010_add_mime_type.sql b/backend/internal/data/migrations/postgres/20250625120010_add_mime_type.sql new file mode 100644 index 00000000..c533d871 --- /dev/null +++ b/backend/internal/data/migrations/postgres/20250625120010_add_mime_type.sql @@ -0,0 +1,3 @@ +-- +goose Up +ALTER TABLE public.attachments ADD COLUMN mime_type VARCHAR DEFAULT 'application/octet-stream'; + diff --git a/backend/internal/data/migrations/sqlite3/20250625120000_add_mime_type.sql b/backend/internal/data/migrations/sqlite3/20250625120000_add_mime_type.sql new file mode 100644 index 00000000..7d766f2a --- /dev/null +++ b/backend/internal/data/migrations/sqlite3/20250625120000_add_mime_type.sql @@ -0,0 +1,3 @@ +-- +goose Up +ALTER TABLE attachments ADD COLUMN mime_type TEXT DEFAULT 'application/octet-stream'; + diff --git a/backend/internal/data/repo/repo_item_attachments.go b/backend/internal/data/repo/repo_item_attachments.go index 94152016..6705c24d 100644 --- a/backend/internal/data/repo/repo_item_attachments.go +++ b/backend/internal/data/repo/repo_item_attachments.go @@ -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 { diff --git a/docs/en/api/openapi-2.0.json b/docs/en/api/openapi-2.0.json index 1b5e301c..52616edb 100644 --- a/docs/en/api/openapi-2.0.json +++ b/docs/en/api/openapi-2.0.json @@ -2191,6 +2191,10 @@ "description": "ID of the ent.", "type": "string" }, + "mime_type": { + "description": "MimeType holds the value of the \"mime_type\" field.", + "type": "string" + }, "path": { "description": "Path holds the value of the \"path\" field.", "type": "string" @@ -3120,6 +3124,9 @@ "id": { "type": "string" }, + "mimeType": { + "type": "string" + }, "path": { "type": "string" }, diff --git a/docs/en/api/openapi-2.0.yaml b/docs/en/api/openapi-2.0.yaml index cce02236..35cb6d8c 100644 --- a/docs/en/api/openapi-2.0.yaml +++ b/docs/en/api/openapi-2.0.yaml @@ -55,6 +55,9 @@ definitions: id: description: ID of the ent. type: string + mime_type: + description: MimeType holds the value of the "mime_type" field. + type: string path: description: Path holds the value of the "path" field. type: string @@ -684,6 +687,8 @@ definitions: type: string id: type: string + mimeType: + type: string path: type: string primary: diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index da315c83..f7876439 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -69,6 +69,8 @@ export interface EntAttachment { edges: EntAttachmentEdges; /** ID of the ent. */ id: string; + /** MimeType holds the value of the "mime_type" field. */ + mime_type: string; /** Path holds the value of the "path" field. */ path: string; /** Primary holds the value of the "primary" field. */ @@ -474,6 +476,7 @@ export interface GroupUpdate { export interface ItemAttachment { createdAt: Date | string; id: string; + mimeType: string; path: string; primary: boolean; thumbnail: EntAttachment; diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index 31dfb057..3b2ae822 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -99,17 +99,28 @@ }; type Photo = { - src: string; + thumbnailSrc?: string; + originalSrc: string; + originalType?: string; }; const photos = computed(() => { + if (!item.value) { + return []; + } return ( - item.value?.attachments.reduce((acc, cur) => { + item.value.attachments.reduce((acc, cur) => { if (cur.type === "photo") { - acc.push({ - // @ts-expect-error - it's impossible for this to be null at this point - src: api.authURL(`/items/${item.value.id}/attachments/${cur.id}`), - }); + const photo: Photo = { + originalSrc: api.authURL(`/items/${item.value!.id}/attachments/${cur.id}`), + originalType: cur.mimeType, + }; + if (cur.thumbnail) { + photo.thumbnailSrc = api.authURL(`/items/${item.value!.id}/attachments/${cur.thumbnail.id}`); + } else { + photo.thumbnailSrc = photo.originalSrc; // fallback to itself if no thumbnail + } + acc.push(photo); } return acc; }, [] as Photo[]) || [] @@ -387,12 +398,14 @@ return v; }); - const dialoged = reactive({ - src: "", + const dialoged = reactive({ + originalSrc: "", }); function openImageDialog(img: Photo) { - dialoged.src = img.src; + dialoged.originalSrc = img.originalSrc; + dialoged.originalType = img.originalType; + dialoged.thumbnailSrc = img.thumbnailSrc; openDialog("item-image"); } @@ -533,8 +546,16 @@ - - + + + attachement image + +