Skip to content

Commit 22fcb13

Browse files
AchoArnoldCopilot
andcommitted
feat: add AttachmentStorage interface and content-type utilities
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 722abc1 commit 22fcb13

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package repositories
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// AttachmentStorage is the interface for storing and retrieving message attachments
11+
type AttachmentStorage interface {
12+
// Upload stores attachment data at the given path
13+
Upload(ctx context.Context, path string, data []byte) error
14+
// Download retrieves attachment data from the given path
15+
Download(ctx context.Context, path string) ([]byte, error)
16+
// Delete removes an attachment at the given path
17+
Delete(ctx context.Context, path string) error
18+
}
19+
20+
// contentTypeExtensions maps MIME types to file extensions
21+
var contentTypeExtensions = map[string]string{
22+
"image/jpeg": ".jpg",
23+
"image/png": ".png",
24+
"image/gif": ".gif",
25+
"image/webp": ".webp",
26+
"image/bmp": ".bmp",
27+
"video/mp4": ".mp4",
28+
"video/3gpp": ".3gp",
29+
"audio/mpeg": ".mp3",
30+
"audio/ogg": ".ogg",
31+
"audio/amr": ".amr",
32+
"application/pdf": ".pdf",
33+
"text/vcard": ".vcf",
34+
"text/x-vcard": ".vcf",
35+
}
36+
37+
// AllowedContentTypes returns the set of allowed MIME types for attachments
38+
func AllowedContentTypes() map[string]bool {
39+
allowed := make(map[string]bool, len(contentTypeExtensions))
40+
for ct := range contentTypeExtensions {
41+
allowed[ct] = true
42+
}
43+
return allowed
44+
}
45+
46+
// ExtensionFromContentType returns the file extension for a MIME content type.
47+
// Returns ".bin" if the content type is not recognized.
48+
func ExtensionFromContentType(contentType string) string {
49+
if ext, ok := contentTypeExtensions[contentType]; ok {
50+
return ext
51+
}
52+
return ".bin"
53+
}
54+
55+
// ContentTypeFromExtension returns the MIME content type for a file extension.
56+
// Returns "application/octet-stream" if the extension is not recognized.
57+
func ContentTypeFromExtension(ext string) string {
58+
for ct, e := range contentTypeExtensions {
59+
if e == ext {
60+
return ct
61+
}
62+
}
63+
return "application/octet-stream"
64+
}
65+
66+
// SanitizeFilename removes path separators and traversal sequences from a filename.
67+
// Returns "attachment-{index}" if the sanitized name is empty.
68+
func SanitizeFilename(name string, index int) string {
69+
name = strings.TrimSuffix(name, filepath.Ext(name))
70+
name = strings.ReplaceAll(name, "/", "")
71+
name = strings.ReplaceAll(name, "\\", "")
72+
name = strings.ReplaceAll(name, "..", "")
73+
name = strings.TrimSpace(name)
74+
75+
if name == "" {
76+
return fmt.Sprintf("attachment-%d", index)
77+
}
78+
return name
79+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package repositories
2+
3+
import "testing"
4+
5+
func TestExtensionFromContentType(t *testing.T) {
6+
tests := []struct {
7+
contentType string
8+
expected string
9+
}{
10+
{"image/jpeg", ".jpg"},
11+
{"image/png", ".png"},
12+
{"image/gif", ".gif"},
13+
{"image/webp", ".webp"},
14+
{"image/bmp", ".bmp"},
15+
{"video/mp4", ".mp4"},
16+
{"video/3gpp", ".3gp"},
17+
{"audio/mpeg", ".mp3"},
18+
{"audio/ogg", ".ogg"},
19+
{"audio/amr", ".amr"},
20+
{"application/pdf", ".pdf"},
21+
{"text/vcard", ".vcf"},
22+
{"text/x-vcard", ".vcf"},
23+
{"application/octet-stream", ".bin"},
24+
{"unknown/type", ".bin"},
25+
{"", ".bin"},
26+
}
27+
for _, tt := range tests {
28+
t.Run(tt.contentType, func(t *testing.T) {
29+
got := ExtensionFromContentType(tt.contentType)
30+
if got != tt.expected {
31+
t.Errorf("ExtensionFromContentType(%q) = %q, want %q", tt.contentType, got, tt.expected)
32+
}
33+
})
34+
}
35+
}
36+
37+
func TestSanitizeFilename(t *testing.T) {
38+
tests := []struct {
39+
name string
40+
index int
41+
expected string
42+
}{
43+
{"photo.jpg", 0, "photo"},
44+
{"../../etc/passwd", 0, "etcpasswd"},
45+
{"hello/world\\test", 0, "helloworldtest"},
46+
{"normal_file", 0, "normal_file"},
47+
{"", 0, "attachment-0"},
48+
{" ", 0, "attachment-0"},
49+
{"...", 1, "attachment-1"},
50+
}
51+
for _, tt := range tests {
52+
t.Run(tt.name, func(t *testing.T) {
53+
got := SanitizeFilename(tt.name, tt.index)
54+
if got != tt.expected {
55+
t.Errorf("SanitizeFilename(%q, %d) = %q, want %q", tt.name, tt.index, got, tt.expected)
56+
}
57+
})
58+
}
59+
}

0 commit comments

Comments
 (0)