feat(config): add attachment MIME allowlist and keybinds

This commit is contained in:
ayn2op 2026-08-31 18:32:33 -04:00
parent 52e7d6ed0e
commit 86c1fd6a77
4 changed files with 101 additions and 6 deletions

View file

@ -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")

View file

@ -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"

View file

@ -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)
}
})
}
}

View file

@ -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"),