package admin import ( "context" "crypto/sha256" "encoding/hex" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "regexp" "strings" ) const maxAssetSizeBytes = 12 * 1024 * 1024 var safeAssetNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) type AssetUploader struct { store *Store assetsDir string } func NewAssetUploader(store *Store, assetsDir string) *AssetUploader { return &AssetUploader{store: store, assetsDir: assetsDir} } func (u *AssetUploader) Upload(ctx context.Context, file multipart.File, header *multipart.FileHeader) (Asset, error) { if header.Size > maxAssetSizeBytes { return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes) } data, err := io.ReadAll(io.LimitReader(file, maxAssetSizeBytes+1)) if err != nil { return Asset{}, fmt.Errorf("read asset: %w", err) } if int64(len(data)) > maxAssetSizeBytes { return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes) } mimeType := http.DetectContentType(data) if !strings.HasPrefix(mimeType, "image/") { return Asset{}, fmt.Errorf("unsupported asset type: %s", mimeType) } sum := sha256.Sum256(data) sha := hex.EncodeToString(sum[:]) name := assetFilename(header.Filename, sha) if err := os.MkdirAll(u.assetsDir, 0o755); err != nil { return Asset{}, fmt.Errorf("create assets dir: %w", err) } if err := os.WriteFile(filepath.Join(u.assetsDir, name), data, 0o644); err != nil { return Asset{}, fmt.Errorf("write asset: %w", err) } asset := Asset{ Path: "/assets/" + name, OriginalName: header.Filename, MimeType: mimeType, SizeBytes: int64(len(data)), SHA256: sha, } return u.store.CreateAsset(ctx, asset) } func assetFilename(original string, sha string) string { ext := strings.ToLower(filepath.Ext(original)) base := strings.TrimSuffix(filepath.Base(original), filepath.Ext(original)) base = strings.Trim(safeAssetNamePattern.ReplaceAllString(base, "-"), "-._") if base == "" { base = "image" } if ext == "" { ext = ".bin" } return fmt.Sprintf("%s-%s%s", base, sha[:12], ext) }