add load applications

This commit is contained in:
2026-08-30 21:17:32 +07:00
parent 3b15fb3a2b
commit c0b5ceb65f
28 changed files with 1536 additions and 139 deletions
+40 -1
View File
@@ -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
}
+48 -3
View File
@@ -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)
}
}
}