From 86c1fd6a7759a61f6b82afb7b4de9b437d2a3f40 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Mon, 31 Aug 2026 18:32:33 -0400 Subject: [PATCH] feat(config): add attachment MIME allowlist and keybinds --- internal/config/config.go | 42 ++++++++++++++++++++++++++++++++++ internal/config/config.toml | 14 +++++++++--- internal/config/config_test.go | 41 ++++++++++++++++++++++++++++++++- internal/config/keybinds.go | 10 ++++++-- 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 247b1e0..f8bb785 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,9 @@ import ( _ "embed" "fmt" "log/slog" + "mime" "os" + "path" "path/filepath" "sync" "unicode/utf8" @@ -106,6 +108,7 @@ type ( Status discord.Status `toml:"status"` HideBlockedUsers bool `toml:"hide_blocked_users"` ShowAttachmentLinks bool `toml:"show_attachment_links"` + AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"` // Use 0 to disable AutocompleteLimit uint8 `toml:"autocomplete_limit"` @@ -170,6 +173,45 @@ func Load(path string) (*Config, error) { return &cfg, nil } +type MIMETypes []string + +func (types *MIMETypes) UnmarshalTOML(value any) error { + values, ok := value.([]any) + if !ok { + return errInvalidType + } + + parsed := make(MIMETypes, 0, len(values)) + for _, value := range values { + mediaType, ok := value.(string) + if !ok { + return errInvalidType + } + mediaType, _, err := mime.ParseMediaType(mediaType) + if err != nil { + return err + } + parsed = append(parsed, mediaType) + } + + *types = parsed + return nil +} + +func (types MIMETypes) Allows(mediaType string) bool { + mediaType, _, err := mime.ParseMediaType(mediaType) + if err != nil { + return false + } + + for _, pattern := range types { + if matches, _ := path.Match(pattern, mediaType); matches { + return true + } + } + return false +} + func applyDefaults(cfg *Config) { if cfg.Editor == "default" { cfg.Editor = os.Getenv("EDITOR") diff --git a/internal/config/config.toml b/internal/config/config.toml index 8120745..41d63a9 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -12,6 +12,9 @@ status = "default" hide_blocked_users = true show_attachment_links = true +# Attachment MIME types that can be opened or downloaded without confirmation. +# Exact types and wildcards such as "image/*" are supported. +allowed_mime_types = ["image/*", "video/*"] # Max members to be in the mention autocomplete suggestions list # Note: Use autocomplete_limit = 0 to disable. @@ -160,11 +163,16 @@ reply = "R" reply_mention = "r" cancel = "esc" edit = "e" -delete = "D" +# Delete without confirmation. +# delete = "D" delete_confirm = "d" -# Open the selected message's attachments or hyperlinks in the message -# using the default browser application. +# Open attachments with their associated application. MIME types outside the +# allowlist require confirmation. Hyperlinks still use the browser. open = "o" +# Open attachments or hyperlinks in the browser. +open_in_browser = "O" +# Download one of the selected message's attachments. +download = "D" # Yank (copy) the selected message's content/url/id. yank_content = "y" yank_url = "u" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 79377c1..a66ca5a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -70,6 +70,17 @@ func TestLoad(t *testing.T) { } }) + t.Run("invalid allowed MIME type returns error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad-mime.toml") + if err := os.WriteFile(path, []byte(`allowed_mime_types = ["image/"]`), os.ModePerm); err != nil { + t.Fatal(err) + } + + if _, err := Load(path); err == nil { + t.Fatal("expected error") + } + }) + // Keys live in config.toml, descriptions in defaultKeybinds(); fail if either is missing. t.Run("default keybinds are fully populated", func(t *testing.T) { cfg, err := Load(filepath.Join(t.TempDir(), "missing.toml")) @@ -84,7 +95,10 @@ func TestLoad(t *testing.T) { fv, name := v.Field(i), path+rt.Field(i).Name if kb, ok := fv.Interface().(Keybind); ok { if len(kb.Keys()) == 0 { - t.Errorf("%s: no keys (missing from config.toml?)", name) + if rt.Field(i).Tag.Get("toml") != "delete" { + t.Errorf("%s: no keys (missing from config.toml?)", name) + } + continue } if kb.Help().Desc == "" { t.Errorf("%s: no help description (missing from defaultKeybinds?)", name) @@ -131,3 +145,28 @@ func TestLoad(t *testing.T) { } }) } + +func TestMIMETypesAllows(t *testing.T) { + tests := []struct { + name string + allowed MIMETypes + mediaType string + wantAllowed bool + }{ + {"exact", []string{"application/pdf"}, "application/pdf", true}, + {"exact mismatch", []string{"application/pdf"}, "application/zip", false}, + {"subtype wildcard", []string{"image/*"}, "image/png", true}, + {"wildcard mismatch", []string{"image/*"}, "video/mp4", false}, + {"all wildcard", []string{"*/*"}, "video/mp4", true}, + {"case and parameters", []string{"image/*"}, "Image/PNG; charset=utf-8", true}, + {"invalid content type", []string{"*/*"}, "not-a-mime-type", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.allowed.Allows(tt.mediaType); got != tt.wantAllowed { + t.Fatalf("Allows(%q) = %v, want %v", tt.mediaType, got, tt.wantAllowed) + } + }) + } +} diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 5ce52ed..1f75364 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -76,11 +76,15 @@ type MessagesListKeybinds struct { Reply Keybind `toml:"reply"` ReplyMention Keybind `toml:"reply_mention"` - Cancel Keybind `toml:"cancel"` - Edit Keybind `toml:"edit"` + Cancel Keybind `toml:"cancel"` + Edit Keybind `toml:"edit"` + Delete Keybind `toml:"delete"` DeleteConfirm Keybind `toml:"delete_confirm"` + Open Keybind `toml:"open"` + OpenInBrowser Keybind `toml:"open_in_browser"` + Download Keybind `toml:"download"` YankContent Keybind `toml:"yank_content"` YankURL Keybind `toml:"yank_url"` @@ -171,6 +175,8 @@ func defaultMessagesListKeybinds() MessagesListKeybinds { Delete: desc("force delete"), DeleteConfirm: desc("delete"), Open: desc("open"), + OpenInBrowser: desc("open browser"), + Download: desc("download"), YankContent: desc("copy text"), YankURL: desc("copy url"), YankID: desc("copy id"),