chore: import clean Agent Desk snapshot (3/7)
This commit is contained in:
23
internal/services/storage/dto.go
Normal file
23
internal/services/storage/dto.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type UploadInfo struct {
|
||||
Prefix string
|
||||
Filename string
|
||||
FileSize int64
|
||||
MimeType string
|
||||
Principal *dto.AuthPrincipal
|
||||
}
|
||||
|
||||
type StoredFile struct {
|
||||
Provider enums.AssetProvider
|
||||
StorageKey string
|
||||
URL string
|
||||
Filename string
|
||||
FileSize int64
|
||||
MimeType string
|
||||
}
|
||||
70
internal/services/storage/local.go
Normal file
70
internal/services/storage/local.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LocalStorage struct {
|
||||
cfg config.LocalStorageConfig
|
||||
}
|
||||
|
||||
func NewLocalStorage(cfg config.LocalStorageConfig) *LocalStorage {
|
||||
return &LocalStorage{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *LocalStorage) ProviderType() enums.AssetProvider {
|
||||
return enums.AssetProviderLocal
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error) {
|
||||
fullPath := filepath.Join(s.cfg.Root, filepath.FromSlash(key))
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dst, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &StoredFile{
|
||||
Provider: enums.AssetProviderLocal,
|
||||
StorageKey: key,
|
||||
URL: strings.TrimRight(s.cfg.BaseURL, "/") + "/" + strings.TrimLeft(key, "/"),
|
||||
Filename: info.Filename,
|
||||
FileSize: info.FileSize,
|
||||
MimeType: info.MimeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LocalStorage) GetURL(key string) string {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.BaseURL), "/")
|
||||
return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(key, "/")
|
||||
}
|
||||
|
||||
func (s *LocalStorage) GetSignedURL(key string) string {
|
||||
return s.GetURL(key)
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Delete(key string) error {
|
||||
fullPath := filepath.Join(s.cfg.Root, filepath.FromSlash(key))
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(fullPath)
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Read(key string) (io.ReadCloser, error) {
|
||||
fullPath := filepath.Join(s.cfg.Root, filepath.FromSlash(strings.TrimSpace(key)))
|
||||
return os.Open(fullPath)
|
||||
}
|
||||
177
internal/services/storage/oss.go
Normal file
177
internal/services/storage/oss.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
type OSSStorage struct {
|
||||
cfg config.OSSStorageConfig
|
||||
}
|
||||
|
||||
func NewOSSStorage(cfg config.OSSStorageConfig) *OSSStorage {
|
||||
return &OSSStorage{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *OSSStorage) ProviderType() enums.AssetProvider {
|
||||
return enums.AssetProviderOSS
|
||||
}
|
||||
|
||||
func (s *OSSStorage) Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error) {
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key = normalizeOSSKey(key)
|
||||
options := []oss.Option{}
|
||||
if mimeType := strings.TrimSpace(info.MimeType); mimeType != "" {
|
||||
options = append(options, oss.ContentType(mimeType))
|
||||
}
|
||||
if info.FileSize > 0 {
|
||||
options = append(options, oss.ContentLength(info.FileSize))
|
||||
}
|
||||
|
||||
if err := bucket.PutObject(key, reader, options...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &StoredFile{
|
||||
Provider: enums.AssetProviderOSS,
|
||||
StorageKey: key,
|
||||
URL: s.GetURL(key),
|
||||
Filename: info.Filename,
|
||||
FileSize: info.FileSize,
|
||||
MimeType: info.MimeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *OSSStorage) GetURL(key string) string {
|
||||
key = normalizeOSSKey(key)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.BaseURL), "/"); baseURL != "" {
|
||||
return baseURL + "/" + key
|
||||
}
|
||||
|
||||
if s.cfg.Private {
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
signedURL, err := bucket.SignURL(key, oss.HTTPGet, s.signedURLExpire())
|
||||
if err == nil {
|
||||
return signedURL
|
||||
}
|
||||
}
|
||||
|
||||
return s.objectURL(key)
|
||||
}
|
||||
|
||||
func (s *OSSStorage) GetSignedURL(key string) string {
|
||||
key = normalizeOSSKey(key)
|
||||
if strs.IsBlank(key) {
|
||||
return ""
|
||||
}
|
||||
if !s.cfg.Private {
|
||||
return s.GetURL(key)
|
||||
}
|
||||
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
signedURL, err := bucket.SignURL(key, oss.HTTPGet, s.signedURLExpire())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return signedURL
|
||||
}
|
||||
|
||||
func (s *OSSStorage) Delete(key string) error {
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.DeleteObject(normalizeOSSKey(key))
|
||||
}
|
||||
|
||||
func (s *OSSStorage) Read(key string) (io.ReadCloser, error) {
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bucket.GetObject(normalizeOSSKey(key))
|
||||
}
|
||||
|
||||
func (s *OSSStorage) getBucket() (*oss.Bucket, error) {
|
||||
if err := s.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := oss.New(
|
||||
s.endpoint(),
|
||||
strings.TrimSpace(s.cfg.AccessKeyID),
|
||||
strings.TrimSpace(s.cfg.AccessKeySecret),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Bucket(strings.TrimSpace(s.cfg.Bucket))
|
||||
}
|
||||
|
||||
func (s *OSSStorage) validate() error {
|
||||
if strings.TrimSpace(s.cfg.Endpoint) == "" {
|
||||
return errorsx.InvalidParamI18n("error.e0051")
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.Bucket) == "" {
|
||||
return errorsx.InvalidParamI18n("error.e0050")
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.AccessKeyID) == "" {
|
||||
return errorsx.InvalidParamI18n("error.e0048")
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.AccessKeySecret) == "" {
|
||||
return errorsx.InvalidParamI18n("error.e0049")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OSSStorage) endpoint() string {
|
||||
endpoint := strings.TrimSpace(s.cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(endpoint, "://") {
|
||||
return endpoint
|
||||
}
|
||||
return "https://" + endpoint
|
||||
}
|
||||
|
||||
func (s *OSSStorage) objectURL(key string) string {
|
||||
u, err := url.Parse(s.endpoint())
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s://%s.%s/%s", u.Scheme, strings.TrimSpace(s.cfg.Bucket), u.Host, key)
|
||||
}
|
||||
|
||||
func (s *OSSStorage) signedURLExpire() int64 {
|
||||
if s.cfg.SignedURLExpire > 0 {
|
||||
return int64(s.cfg.SignedURLExpire)
|
||||
}
|
||||
return 600
|
||||
}
|
||||
|
||||
func normalizeOSSKey(key string) string {
|
||||
return strings.TrimLeft(strings.TrimSpace(key), "/")
|
||||
}
|
||||
50
internal/services/storage/provider.go
Normal file
50
internal/services/storage/provider.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"io"
|
||||
)
|
||||
|
||||
var providers = make(map[enums.AssetProvider]FileStorageProvider)
|
||||
|
||||
type FileStorageProvider interface {
|
||||
ProviderType() enums.AssetProvider
|
||||
Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error)
|
||||
GetURL(key string) string
|
||||
GetSignedURL(key string) string
|
||||
Delete(key string) error
|
||||
Read(key string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
func GetDefault() (FileStorageProvider, error) {
|
||||
return NewProvider(config.Current().Storage.Default)
|
||||
}
|
||||
|
||||
func GetProvider(providerType enums.AssetProvider) (FileStorageProvider, error) {
|
||||
if provider, exists := providers[providerType]; exists {
|
||||
return provider, nil
|
||||
}
|
||||
provider, err := NewProvider(providerType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
providers[providerType] = provider
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func NewProvider(provider enums.AssetProvider) (FileStorageProvider, error) {
|
||||
cfg := config.Current().Storage
|
||||
|
||||
switch provider {
|
||||
case "", enums.AssetProviderLocal:
|
||||
return NewLocalStorage(cfg.Local), nil
|
||||
case enums.AssetProviderOSS:
|
||||
return NewOSSStorage(cfg.OSS), nil
|
||||
default:
|
||||
return nil, errorsx.InvalidParamI18n("error.e0082")
|
||||
}
|
||||
}
|
||||
218
internal/services/storage/safety.go
Normal file
218
internal/services/storage/safety.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
)
|
||||
|
||||
const (
|
||||
// sniffLimit is the number of leading bytes net/http.DetectContentType inspects.
|
||||
sniffLimit = 512
|
||||
// maxFilenameLength keeps a stored filename inside the column that holds it.
|
||||
maxFilenameLength = 255
|
||||
)
|
||||
|
||||
// previewableExtensions are the only stored file types a browser may render
|
||||
// inline. Every other extension is forced to download.
|
||||
//
|
||||
// Assets are served from this application's own origin, so an inline response is
|
||||
// same-origin content: a browser that navigates to it runs whatever the bytes
|
||||
// ask for, in the same security context as the dashboard and the public support
|
||||
// widget. Forcing a download for anything we have not vetted keeps that true
|
||||
// only for media, which cannot touch the DOM.
|
||||
var previewableExtensions = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||
".bmp": true, ".ico": true, ".avif": true, ".tif": true, ".tiff": true,
|
||||
".heic": true, ".heif": true,
|
||||
".mp4": true, ".m4v": true, ".webm": true, ".mov": true, ".ogv": true,
|
||||
".mp3": true, ".m4a": true, ".wav": true, ".ogg": true, ".oga": true,
|
||||
".aac": true, ".flac": true,
|
||||
".pdf": true,
|
||||
}
|
||||
|
||||
// blockedExtensions are rejected at upload time. Each one names a document a
|
||||
// browser executes or lays out instead of displaying, so accepting it would let
|
||||
// an unauthenticated visitor plant a script that runs against this origin the
|
||||
// moment a staff member opens the attachment link.
|
||||
var blockedExtensions = map[string]bool{
|
||||
".html": true, ".htm": true, ".shtml": true, ".xhtml": true, ".xht": true,
|
||||
".svg": true, ".svgz": true,
|
||||
".xml": true, ".xsl": true, ".xslt": true, ".xsd": true, ".wsdl": true,
|
||||
".js": true, ".mjs": true, ".cjs": true,
|
||||
".swf": true,
|
||||
".php": true, ".phtml": true, ".php3": true, ".php4": true, ".php5": true,
|
||||
".asp": true, ".aspx": true, ".jsp": true, ".jspx": true, ".cfm": true,
|
||||
".hta": true, ".htc": true, ".htaccess": true,
|
||||
}
|
||||
|
||||
// blockedMediaTypes are the payloads a browser renders as an active document.
|
||||
// They are rejected regardless of the extension the client chose, so renaming a
|
||||
// page to .png does not smuggle it past the extension check.
|
||||
var blockedMediaTypes = map[string]bool{
|
||||
"text/html": true,
|
||||
"application/xhtml+xml": true,
|
||||
"image/svg+xml": true,
|
||||
"text/xml": true,
|
||||
"application/xml": true,
|
||||
"application/xslt+xml": true,
|
||||
"text/javascript": true,
|
||||
"application/javascript": true,
|
||||
"application/x-javascript": true,
|
||||
}
|
||||
|
||||
// blockedUploadI18nKey is returned to the uploader whenever the file-safety
|
||||
// policy rejects a payload. It deliberately does not name the offending type.
|
||||
const blockedUploadI18nKey = "error.e0348"
|
||||
|
||||
// mediaTypeExtensions maps a MIME type to the extension it should be stored
|
||||
// under. mime.ExtensionsByType cannot be used here: on Windows it consults the
|
||||
// registry, so the same type resolves to a different extension than in
|
||||
// production, and it happily hands back an extension that describes an active
|
||||
// document. Types that must never become a servable document are absent, which
|
||||
// leaves the caller with the inert .bin fallback.
|
||||
var mediaTypeExtensions = map[string]string{
|
||||
// images
|
||||
"image/jpeg": ".jpg", "image/jfif": ".jpg", "image/pjpeg": ".jpg",
|
||||
"image/png": ".png", "image/gif": ".gif", "image/webp": ".webp",
|
||||
"image/bmp": ".bmp", "image/tiff": ".tiff", "image/avif": ".avif",
|
||||
"image/heic": ".heic", "image/heif": ".heif", "image/x-icon": ".ico",
|
||||
"image/vnd.microsoft.icon": ".ico",
|
||||
// documents
|
||||
"application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv",
|
||||
"text/markdown": ".md", "application/json": ".json", "application/rtf": ".rtf",
|
||||
"application/msword": ".doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/vnd.ms-excel": ".xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.ms-powerpoint": ".ppt",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
"application/vnd.oasis.opendocument.text": ".odt",
|
||||
"application/vnd.oasis.opendocument.spreadsheet": ".ods",
|
||||
"application/vnd.oasis.opendocument.presentation": ".odp",
|
||||
// archives
|
||||
"application/zip": ".zip", "application/x-zip-compressed": ".zip",
|
||||
"application/x-rar-compressed": ".rar", "application/vnd.rar": ".rar",
|
||||
"application/x-7z-compressed": ".7z", "application/x-tar": ".tar",
|
||||
"application/gzip": ".gz", "application/x-bzip2": ".bz2", "application/x-xz": ".xz",
|
||||
// audio
|
||||
"audio/mpeg": ".mp3", "audio/mp4": ".m4a", "audio/x-m4a": ".m4a",
|
||||
"audio/wav": ".wav", "audio/x-wav": ".wav", "audio/ogg": ".ogg",
|
||||
"audio/aac": ".aac", "audio/flac": ".flac", "audio/webm": ".webm",
|
||||
// video
|
||||
"video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov",
|
||||
"video/x-msvideo": ".avi", "video/x-matroska": ".mkv", "video/ogg": ".ogv",
|
||||
"video/mpeg": ".mpeg",
|
||||
}
|
||||
|
||||
// safeExtensionForMediaType returns the extension a payload of this type should
|
||||
// be stored under, or "" when the type is not one this origin is willing to
|
||||
// serve as a document.
|
||||
func safeExtensionForMediaType(mediaType string) string {
|
||||
return mediaTypeExtensions[strings.ToLower(strings.TrimSpace(mediaType))]
|
||||
}
|
||||
|
||||
// IsPreviewableExtension reports whether a stored file may be rendered inline.
|
||||
func IsPreviewableExtension(ext string) bool {
|
||||
return previewableExtensions[normalizeExt(ext)]
|
||||
}
|
||||
|
||||
// IsBlockedExtension reports whether an extension names a browser-active document.
|
||||
func IsBlockedExtension(ext string) bool {
|
||||
return blockedExtensions[normalizeExt(ext)]
|
||||
}
|
||||
|
||||
// IsBlockedMediaType reports whether a MIME type describes a browser-active document.
|
||||
func IsBlockedMediaType(mediaType string) bool {
|
||||
parsed, _, err := mime.ParseMediaType(strings.TrimSpace(mediaType))
|
||||
if err != nil || parsed == "" {
|
||||
return false
|
||||
}
|
||||
return blockedMediaTypes[strings.ToLower(parsed)]
|
||||
}
|
||||
|
||||
// SanitizeFilename reduces a client-supplied name to a bare basename. Uploads
|
||||
// arrive from browsers, mobile SDKs and channel webhooks, all of which are free
|
||||
// to send a full path, control characters or nothing at all; the result is stored
|
||||
// on the asset and echoed back into message payloads and download links.
|
||||
func SanitizeFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
name = path.Base(strings.ReplaceAll(name, "\\", "/"))
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
name = strings.TrimSpace(strings.TrimRight(name, ". "))
|
||||
if len(name) > maxFilenameLength {
|
||||
ext := path.Ext(name)
|
||||
if len(ext) > maxFilenameLength {
|
||||
ext = ""
|
||||
}
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
// Cut on a rune boundary: a partial multibyte character would not survive
|
||||
// the round trip through a utf8mb4 column.
|
||||
limit := maxFilenameLength - len(ext)
|
||||
for limit > 0 && !utf8.RuneStart(stem[limit]) {
|
||||
limit--
|
||||
}
|
||||
name = stem[:limit] + ext
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// SniffContentType reports what the leading bytes of a seekable payload actually
|
||||
// are, then rewinds so the caller can still stream the whole file to storage.
|
||||
// The Content-Type a client declares is a claim, not evidence.
|
||||
func SniffContentType(src io.ReadSeeker) (string, error) {
|
||||
head := make([]byte, sniffLimit)
|
||||
read, err := io.ReadFull(src, head)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return http.DetectContentType(head[:read]), nil
|
||||
}
|
||||
|
||||
// ValidateUpload applies the file-safety policy to an upload and returns the MIME
|
||||
// type that should be recorded on the asset.
|
||||
//
|
||||
// sniffed comes from SniffContentType and wins whenever it identified something
|
||||
// specific; declared is what the client claimed and is only trusted for the
|
||||
// formats net/http cannot recognise, such as HEIC and most Office documents.
|
||||
func ValidateUpload(filename, declared, sniffed string) (string, error) {
|
||||
if IsBlockedExtension(path.Ext(filename)) {
|
||||
return "", errorsx.InvalidParamI18n(blockedUploadI18nKey)
|
||||
}
|
||||
if IsBlockedMediaType(sniffed) || IsBlockedMediaType(declared) {
|
||||
return "", errorsx.InvalidParamI18n(blockedUploadI18nKey)
|
||||
}
|
||||
|
||||
mediaType, _, _ := mime.ParseMediaType(sniffed)
|
||||
if mediaType != "" && mediaType != "application/octet-stream" && !strings.HasPrefix(mediaType, "text/") {
|
||||
return sniffed, nil
|
||||
}
|
||||
if declared != "" {
|
||||
return declared, nil
|
||||
}
|
||||
return sniffed, nil
|
||||
}
|
||||
|
||||
func normalizeExt(ext string) string {
|
||||
ext = strings.ToLower(strings.TrimSpace(ext))
|
||||
if ext != "" && !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
return ext
|
||||
}
|
||||
268
internal/services/storage/safety_test.go
Normal file
268
internal/services/storage/safety_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var pngSignature = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
|
||||
|
||||
func TestValidateUploadBlocksBrowserActiveExtensions(t *testing.T) {
|
||||
names := []string{
|
||||
"session-stealer.html", "page.HTM", "vector.svg", "sheet.xml",
|
||||
"transform.xsl", "bundle.min.js", "app.mjs", "shell.php",
|
||||
"report.asp", "index.jsp", "legacy.swf", "widget.htc",
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, err := ValidateUpload(name, "application/octet-stream", "application/octet-stream"); err == nil {
|
||||
t.Errorf("ValidateUpload(%q) accepted a browser-active extension", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUploadBlocksStoredXSSPayload(t *testing.T) {
|
||||
payload := []byte(`<html><body><script>fetch("/api/dashboard/user/current")</script></body></html>`)
|
||||
sniffed, err := SniffContentType(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("SniffContentType() error = %v", err)
|
||||
}
|
||||
if !IsBlockedMediaType(sniffed) {
|
||||
t.Fatalf("expected the payload to sniff as a blocked media type, got %q", sniffed)
|
||||
}
|
||||
|
||||
if _, err := ValidateUpload("proof.html", "text/html", sniffed); err == nil {
|
||||
t.Error("expected an honestly named HTML upload to be rejected")
|
||||
}
|
||||
// Renaming the payload is the actual attack: the extension looks like an
|
||||
// image and the declared type agrees, so only the bytes can catch it.
|
||||
if _, err := ValidateUpload("profile.png", "image/png", sniffed); err == nil {
|
||||
t.Error("expected an HTML payload disguised as a PNG to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUploadBlocksSVGRegardlessOfDeclaredType(t *testing.T) {
|
||||
payload := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
|
||||
sniffed, err := SniffContentType(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("SniffContentType() error = %v", err)
|
||||
}
|
||||
if _, err := ValidateUpload("logo.svg", "image/svg+xml", sniffed); err == nil {
|
||||
t.Error("expected an SVG upload to be rejected")
|
||||
}
|
||||
// An SVG that arrives without the .svg extension is still refused, because
|
||||
// the declared type alone is enough to identify it as an active document.
|
||||
if _, err := ValidateUpload("logo.png", "image/svg+xml", sniffed); err == nil {
|
||||
t.Error("expected an SVG declared as an image to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUploadAcceptsOrdinarySupportFiles(t *testing.T) {
|
||||
cases := []struct {
|
||||
filename string
|
||||
declared string
|
||||
sniffed string
|
||||
wantMime string
|
||||
}{
|
||||
{"screenshot.png", "image/png", http.DetectContentType(pngSignature), "image/png"},
|
||||
// The client declared a generic type; the payload identified itself.
|
||||
{"photo.jpg", "application/octet-stream", "image/jpeg", "image/jpeg"},
|
||||
// net/http cannot recognise HEIC, so the declared type has to stand in.
|
||||
{"IMG_0001.heic", "image/heic", "application/octet-stream", "image/heic"},
|
||||
{"contract.pdf", "application/pdf", "application/pdf", "application/pdf"},
|
||||
{"export.csv", "text/csv", "text/plain; charset=utf-8", "text/csv"},
|
||||
{"notes.txt", "text/plain", "text/plain; charset=utf-8", "text/plain"},
|
||||
{"logs.zip", "application/zip", "application/zip", "application/zip"},
|
||||
{"build.apk", "application/vnd.android.package-archive", "application/octet-stream", "application/vnd.android.package-archive"},
|
||||
{"data.json", "application/json", "application/json", "application/json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := ValidateUpload(tc.filename, tc.declared, tc.sniffed)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateUpload(%q) error = %v", tc.filename, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.wantMime {
|
||||
t.Errorf("ValidateUpload(%q) mime = %q want %q", tc.filename, got, tc.wantMime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"report.pdf", "report.pdf"},
|
||||
{"../../etc/passwd", "passwd"},
|
||||
{`C:\Users\victim\Desktop\evil.html`, "evil.html"},
|
||||
{"/absolute/path/notes.txt", "notes.txt"},
|
||||
{"tab\tand\nnewline.log", "tabandnewline.log"},
|
||||
{"....", ""},
|
||||
{"", ""},
|
||||
{" spaced name.pdf ", "spaced name.pdf"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := SanitizeFilename(tc.in); got != tc.want {
|
||||
t.Errorf("SanitizeFilename(%q) = %q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
long := strings.Repeat("a", 300) + ".pdf"
|
||||
got := SanitizeFilename(long)
|
||||
if len(got) > 255 {
|
||||
t.Errorf("SanitizeFilename() len = %d want <= 255", len(got))
|
||||
}
|
||||
if !strings.HasSuffix(got, ".pdf") {
|
||||
t.Errorf("SanitizeFilename() = %q, expected the extension to survive truncation", got)
|
||||
}
|
||||
|
||||
// A multibyte stem must not be cut in the middle of a rune.
|
||||
wide := strings.Repeat("附件", 100) + ".pdf"
|
||||
got = SanitizeFilename(wide)
|
||||
if len(got) > 255 {
|
||||
t.Errorf("SanitizeFilename() len = %d want <= 255", len(got))
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("SanitizeFilename() = %q is not valid UTF-8", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, ".pdf") {
|
||||
t.Errorf("SanitizeFilename() = %q, expected the extension to survive truncation", got)
|
||||
}
|
||||
|
||||
// An extension longer than the whole budget cannot be preserved.
|
||||
got = SanitizeFilename("a." + strings.Repeat("b", 300))
|
||||
if len(got) > 255 {
|
||||
t.Errorf("SanitizeFilename() len = %d want <= 255", len(got))
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("SanitizeFilename() = %q is not valid UTF-8", got)
|
||||
}
|
||||
|
||||
// A name made entirely of dots leaves nothing worth keeping.
|
||||
if got = SanitizeFilename(strings.Repeat(".", 300)); got != "" {
|
||||
t.Errorf("SanitizeFilename() of a dot-only name = %q want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffContentTypeRewindsTheReader(t *testing.T) {
|
||||
src := bytes.NewReader(pngSignature)
|
||||
got, err := SniffContentType(src)
|
||||
if err != nil {
|
||||
t.Fatalf("SniffContentType() error = %v", err)
|
||||
}
|
||||
if got != "image/png" {
|
||||
t.Fatalf("SniffContentType() = %q want image/png", got)
|
||||
}
|
||||
|
||||
// The caller still streams the whole payload to storage afterwards, so the
|
||||
// reader must be back at the start.
|
||||
rest, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(rest, pngSignature) {
|
||||
t.Fatalf("reader was not rewound: got %d bytes want %d", len(rest), len(pngSignature))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffContentTypeHandlesShortAndEmptyPayloads(t *testing.T) {
|
||||
short := bytes.NewReader([]byte("hi"))
|
||||
got, err := SniffContentType(short)
|
||||
if err != nil {
|
||||
t.Fatalf("SniffContentType() error = %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got, "text/plain") {
|
||||
t.Fatalf("SniffContentType() = %q want a text/plain result", got)
|
||||
}
|
||||
rest, err := io.ReadAll(short)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll() error = %v", err)
|
||||
}
|
||||
if string(rest) != "hi" {
|
||||
t.Fatalf("reader was not rewound: got %q", rest)
|
||||
}
|
||||
|
||||
empty := bytes.NewReader(nil)
|
||||
if _, err := SniffContentType(empty); err != nil {
|
||||
t.Fatalf("SniffContentType() on an empty payload error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateStorageKeyNeverUsesBlockedExtension(t *testing.T) {
|
||||
cases := []struct {
|
||||
info UploadInfo
|
||||
want string
|
||||
}{
|
||||
{UploadInfo{Filename: "evil.html", MimeType: "text/html"}, ".bin"},
|
||||
{UploadInfo{Filename: "evil.svg", MimeType: "image/svg+xml"}, ".bin"},
|
||||
// The extension is gone but the MIME type still names an active
|
||||
// document, so the derived extension must be refused too.
|
||||
{UploadInfo{Filename: "noext", MimeType: "text/html"}, ".bin"},
|
||||
{UploadInfo{Filename: "notes.txt", MimeType: "text/plain"}, ".txt"},
|
||||
{UploadInfo{Filename: "photo.png", MimeType: "image/png"}, ".png"},
|
||||
{UploadInfo{Filename: "unknown", MimeType: "application/octet-stream"}, ".bin"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, key := GenerateStorageKey(tc.info)
|
||||
if !strings.HasSuffix(key, tc.want) {
|
||||
t.Errorf("GenerateStorageKey(%+v) = %q, expected it to end in %q", tc.info, key, tc.want)
|
||||
}
|
||||
if strings.Contains(key, "..") {
|
||||
t.Errorf("GenerateStorageKey(%+v) = %q contains a traversal segment", tc.info, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExtByMimeTypeIsPlatformIndependent(t *testing.T) {
|
||||
// mime.ExtensionsByType reads the Windows registry, where text/html resolved
|
||||
// to ".ehtml" and slipped past the block list. The mapping has to come from
|
||||
// our own table so a storage key is the same on every platform, and so a
|
||||
// media type that describes an active document maps to nothing at all.
|
||||
cases := map[string]string{
|
||||
"text/html": "",
|
||||
"application/xhtml+xml": "",
|
||||
"image/svg+xml": "",
|
||||
"text/xml": "",
|
||||
"application/xml": "",
|
||||
"application/javascript": "",
|
||||
"text/javascript": "",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jfif": ".jpg",
|
||||
"image/pjpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"application/pdf": ".pdf",
|
||||
"text/csv": ".csv",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
}
|
||||
for mediaType, want := range cases {
|
||||
if got := getExtByMimeType(mediaType); got != want {
|
||||
t.Errorf("getExtByMimeType(%q) = %q want %q", mediaType, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
for mediaType := range blockedMediaTypes {
|
||||
if got := getExtByMimeType(mediaType); got != "" {
|
||||
t.Errorf("getExtByMimeType(%q) = %q, a blocked media type must not yield an extension", mediaType, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPreviewableExtension(t *testing.T) {
|
||||
inline := []string{".png", ".JPG", ".jpeg", ".gif", ".webp", ".avif", ".mp4", ".webm", ".mp3", ".pdf"}
|
||||
for _, ext := range inline {
|
||||
if !IsPreviewableExtension(ext) {
|
||||
t.Errorf("IsPreviewableExtension(%q) = false want true", ext)
|
||||
}
|
||||
}
|
||||
|
||||
download := []string{".html", ".svg", ".xml", ".js", ".zip", ".txt", ".csv", ".docx", ".apk", ""}
|
||||
for _, ext := range download {
|
||||
if IsPreviewableExtension(ext) {
|
||||
t.Errorf("IsPreviewableExtension(%q) = true want false", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
internal/services/storage/utils.go
Normal file
78
internal/services/storage/utils.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
func GenerateStorageKey(info UploadInfo) (assetID string, storageKey string) {
|
||||
assetID = strs.UUID()
|
||||
var (
|
||||
env = currentAssetEnv()
|
||||
prefix = normalizeAssetPrefix(info.Prefix)
|
||||
datePath = time.Now().Format("2006/01/02")
|
||||
ext = getExt(info)
|
||||
)
|
||||
|
||||
storageKey = filepath.Join(
|
||||
env,
|
||||
prefix,
|
||||
datePath,
|
||||
assetID+ext,
|
||||
)
|
||||
storageKey = strings.TrimLeft(filepath.ToSlash(storageKey), "/")
|
||||
return
|
||||
}
|
||||
|
||||
func getExt(info UploadInfo) string {
|
||||
ext := normalizeExt(filepath.Ext(strings.TrimSpace(info.Filename)))
|
||||
if ext == "" || IsBlockedExtension(ext) {
|
||||
ext = normalizeExt(getExtByMimeType(info.MimeType))
|
||||
}
|
||||
if ext == "" || IsBlockedExtension(ext) {
|
||||
// The stored extension decides the Content-Type this origin serves. A
|
||||
// browser-active extension must never reach the key, and no extension at
|
||||
// all would leave the server to sniff the payload and announce whatever
|
||||
// it finds, so both cases settle on an inert binary type.
|
||||
return ".bin"
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
func getExtByMimeType(mimeType string) string {
|
||||
if strs.IsBlank(mimeType) {
|
||||
return ""
|
||||
}
|
||||
|
||||
mediaType, _, _ := mime.ParseMediaType(mimeType)
|
||||
if mediaType == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return safeExtensionForMediaType(mediaType)
|
||||
}
|
||||
|
||||
func normalizeAssetPrefix(prefix string) string {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
prefix = strings.ReplaceAll(prefix, "..", "")
|
||||
prefix = filepath.ToSlash(prefix)
|
||||
for strings.Contains(prefix, "//") {
|
||||
prefix = strings.ReplaceAll(prefix, "//", "/")
|
||||
}
|
||||
return strings.Trim(prefix, "/")
|
||||
}
|
||||
|
||||
func currentAssetEnv() string {
|
||||
for _, key := range []string{"APP_ENV", "GO_ENV"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user