generated from VLADIMIR/template
add load applications
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
package file_storage
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrFileNotFound — файл не найден в хранилище. Возвращается Get для
|
||||
// отсутствующих объектов (маппинг smithy-ошибок NoSuchKey/NotFound).
|
||||
var ErrFileNotFound = errors.New("файл не найден")
|
||||
|
||||
type File struct {
|
||||
Name string
|
||||
@@ -14,3 +24,32 @@ type IFileStorage interface {
|
||||
Delete(ctx context.Context, filename string) error
|
||||
MimeType(filename string) string
|
||||
}
|
||||
|
||||
// FileType возвращает категорию файла по расширению имени: pdf → "pdf",
|
||||
// jpg/jpeg/png/gif/webp → "image", mp3/ogg/wav → "audio", иначе "".
|
||||
// Регистр расширения и query-параметры игнорируются.
|
||||
// Используется для деривации file_type улик (см. storytelling.Application).
|
||||
func FileType(filename string) string {
|
||||
ext := strings.ToLower(extension(filename))
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
return "pdf"
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
|
||||
return "image"
|
||||
case ".mp3", ".ogg", ".wav":
|
||||
return "audio"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// NewName возвращает случайное имя файла в хранилище (hex-токен 16 байт
|
||||
// без расширения): исключает коллизии в общем бакете. Расширение добавляет
|
||||
// вызывающий код.
|
||||
func NewName() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package file_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/smithy-go"
|
||||
"github.com/kzzan/s3kit"
|
||||
)
|
||||
|
||||
@@ -47,19 +51,27 @@ func NewRustFSStorage(
|
||||
}
|
||||
|
||||
func (s *storage) Put(ctx context.Context, file *File) error {
|
||||
mime := s.MimeType(file.Name)
|
||||
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mime)
|
||||
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mimeFor(file))
|
||||
}
|
||||
|
||||
func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
|
||||
data, err := s.client.GetObjectBytes(ctx, s.bucket, filename)
|
||||
if err != nil {
|
||||
// s3kit отдаёт сырые smithy-ошибки: маппим отсутствие объекта
|
||||
// (NoSuchKey/NotFound) в sentinel для прикладного слоя.
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
case "NoSuchKey", "NotFound":
|
||||
return nil, ErrFileNotFound
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &File{
|
||||
Name: filename,
|
||||
Data: data,
|
||||
Mime: s.MimeType(filename),
|
||||
Mime: mimeFromData(data, filename),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -73,3 +85,36 @@ func (s *storage) MimeType(filename string) string {
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// mimeFor — Content-Type для Put: явный Mime файла приоритетнее вывода из
|
||||
// расширения, чтобы объект в S3 соответствовал фактическому содержимому.
|
||||
func mimeFor(f *File) string {
|
||||
if f.Mime != "" {
|
||||
return f.Mime
|
||||
}
|
||||
return mimeFromData(f.Data, f.Name)
|
||||
}
|
||||
|
||||
// mimeFromData — Content-Type по содержимому (первые 512 байт) с fallback
|
||||
// на расширение. text/* (например, HTML) трактуется как octet-stream и
|
||||
// уходит в fallback: при публичном скачивании не должно отдаваться как
|
||||
// html с доверенного домена.
|
||||
func mimeFromData(data []byte, filename string) string {
|
||||
detected := http.DetectContentType(data)
|
||||
if strings.HasPrefix(detected, "text/") || detected == "application/octet-stream" {
|
||||
if mimeType := mime.TypeByExtension(filepath.Ext(filename)); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return detected
|
||||
}
|
||||
|
||||
// extension — расширение имени файла в нижнем регистре, без query/fragment
|
||||
// (например, "photo.PNG?v=2" → ".png").
|
||||
func extension(filename string) string {
|
||||
if i := strings.IndexAny(filename, "?#"); i >= 0 {
|
||||
filename = filename[:i]
|
||||
}
|
||||
return filepath.Ext(strings.ToLower(filename))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package file_storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileType(t *testing.T) {
|
||||
cases := []struct {
|
||||
filename string
|
||||
want string
|
||||
}{
|
||||
{"clue.pdf", "pdf"},
|
||||
{"clue.PDF", "pdf"},
|
||||
{"photo.jpg", "image"},
|
||||
{"photo.jpeg", "image"},
|
||||
{"photo.PNG", "image"},
|
||||
{"photo.gif", "image"},
|
||||
{"photo.webp", "image"},
|
||||
{"audio.mp3", "audio"},
|
||||
{"audio.ogg", "audio"},
|
||||
{"audio.wav", "audio"},
|
||||
{"audio.m4a", ""},
|
||||
{"photo.PNG?v=2", "image"},
|
||||
{"http://domain/api/files/photo.pdf", "pdf"},
|
||||
{"noextension", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FileType(c.filename); got != c.want {
|
||||
t.Errorf("FileType(%q) = %q, want %q", c.filename, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewName(t *testing.T) {
|
||||
a, err := NewName()
|
||||
if err != nil {
|
||||
t.Fatalf("NewName: %v", err)
|
||||
}
|
||||
b, err := NewName()
|
||||
if err != nil {
|
||||
t.Fatalf("NewName: %v", err)
|
||||
}
|
||||
if a == "" || b == "" {
|
||||
t.Fatalf("NewName вернул пустое имя")
|
||||
}
|
||||
if a == b {
|
||||
t.Errorf("NewName вернул одинаковые имена %q и %q", a, b)
|
||||
}
|
||||
// hex-токен 16 байт = 32 символа
|
||||
if len(a) != 32 {
|
||||
t.Errorf("длина имени = %d, want 32", len(a))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimeFor(t *testing.T) {
|
||||
png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 16))
|
||||
|
||||
t.Run("explicit Mime wins", func(t *testing.T) {
|
||||
f := &File{Name: "clue.pdf", Data: png, Mime: "application/pdf"}
|
||||
if got := mimeFor(f); got != "application/pdf" {
|
||||
t.Errorf("mimeFor = %q, want application/pdf", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty Mime falls back to content sniff", func(t *testing.T) {
|
||||
f := &File{Name: "clue.bin", Data: png}
|
||||
if got := mimeFor(f); got != "image/png" {
|
||||
t.Errorf("mimeFor = %q, want image/png", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty Mime and unknown content falls back to extension", func(t *testing.T) {
|
||||
f := &File{Name: "clue.pdf", Data: []byte("not really a pdf")}
|
||||
if got := mimeFor(f); got != "application/pdf" {
|
||||
t.Errorf("mimeFor = %q, want application/pdf по расширению", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMimeFromData(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
filename string
|
||||
want string
|
||||
}{
|
||||
{"pdf", []byte("%PDF-1.7 fake"), "clue.pdf", "application/pdf"},
|
||||
{"png", []byte("\x89PNG\r\n\x1a\nxxx"), "photo.png", "image/png"},
|
||||
{"html falls back to extension", []byte("<html>hi</html>"), "photo.png", "image/png"},
|
||||
{"html unknown extension", []byte("<html>hi</html>"), "clue.xyz", "application/octet-stream"},
|
||||
{"unknown falls back to extension", []byte{0x00, 0x01, 0x02, 0x03}, "clue.pdf", "application/pdf"},
|
||||
{"empty data falls back", nil, "clue.pdf", "application/pdf"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := mimeFromData(c.data, c.filename); got != c.want {
|
||||
t.Errorf("mimeFromData(%q, %q) = %q, want %q", c.name, c.filename, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user