diff --git a/pkg/files/field.go b/pkg/files/field.go new file mode 100644 index 0000000000000000000000000000000000000000..5ce4dc4ce72506fdd62e184b95487319f95e5122 --- /dev/null +++ b/pkg/files/field.go @@ -0,0 +1,156 @@ +package files + +import ( + "context" + "errors" + "fmt" + "net/url" + "reflect" + + "git.perx.ru/perxis/perxis-go/pkg/items" + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + signer "git.perx.ru/perxis/perxis-go/pkg/urlsigner" + "github.com/mitchellh/mapstructure" +) + +const FileTypeName = "file" + +type FileParameters struct { + t *FileType +} + +func (p FileParameters) Type() field.Type { return p.t } +func (p *FileParameters) Clone(reset bool) field.Parameters { return p } + +type FileType struct { + fs Files + signer signer.URLSigner + fileServerUrl string + uploader Uploader +} + +func NewFileType(fs Files, signer signer.URLSigner, fileServerUrl string) *FileType { + return &FileType{fs: fs, signer: signer, fileServerUrl: fileServerUrl, uploader: NewUploader()} +} + +func (t *FileType) WithUploader(uploader Uploader) *FileType { + t.uploader = uploader + return t +} + +func (t FileType) Name() string { return FileTypeName } + +func (t *FileType) NewParameters() field.Parameters { + return &FileParameters{t} +} + +func (t FileType) Decode(_ context.Context, fld *field.Field, v interface{}) (interface{}, error) { + if v == nil { + return nil, nil + } + var f File + if err := mapstructure.Decode(v, &f); err != nil { + return nil, err + } + return &f, nil +} + +func (t FileType) Encode(ctx context.Context, fld *field.Field, v interface{}) (interface{}, error) { + if v == nil { + return nil, nil + } + + f, ok := v.(*File) + if !ok { + return nil, fmt.Errorf("FileField encode error: incorrect type: \"%s\", expected \"file\"", reflect.ValueOf(v).Kind()) + } + + if f.File != nil { // upload from file system + upload, err := t.fs.Upload(ctx, f) + if err != nil { + return nil, err + } + if err = t.uploader.Upload(f.File, upload); err != nil { + return nil, err + } + f = &upload.File + } + + u := fmt.Sprintf("%s/%s", t.fileServerUrl, f.ID) + resURL, err := url.Parse(u) + if err != nil { + return nil, err + } + if t.signer != nil { + f.URL = t.signer.Sign(resURL).String() + } else { + f.URL = resURL.String() + } + res := make(map[string]interface{}) + if err := mapstructure.Decode(f, &res); err != nil { + return nil, err + } + + return res, nil + +} + +// PreSave - функция Р±СѓРґРµ вызвана перед сохранением поля РІ Storage. Реализует интерфейс `perxis.PreSaver` +// Выполняет проверку поля является ли файл только что загруженным Рё переносит его РїСЂРё необходимости для +// постоянного хранения +func (t FileType) PreSave(ctx context.Context, fld *field.Field, v interface{}, itemCtx *items.Context) (interface{}, bool, error) { + if v == nil { + return nil, false, nil + } + f := v.(*File) + if f.ID == "" { + return nil, false, errors.New("FileType: file id required") + } + if !f.Temporary() { + return f, false, nil + } + + f, err := t.fs.MoveUpload(ctx, &MultipartUpload{File: *f}) + if err != nil { + return nil, false, err + } + return f, true, nil +} + +// Field - создает РЅРѕРІРѕРµ поле Field типа FileType +// FileType должен быть предварительно создан `NewFileType` Рё зарегистрирован `field.Register` +func Field(o ...interface{}) *field.Field { + t, ok := field.GetType(FileTypeName) + if !ok { + panic("field file type not registered") + } + return field.NewField(t.NewParameters(), o...) +} + +func (t *FileType) IsEmpty(v interface{}) bool { + if v == nil { + return true + } + + f, ok := v.(*File) + + return !ok || f.ID == "" +} + +func (p FileParameters) GetField(path string) (fld *field.Field) { + switch path { + case "id", "name", "mimeType", "url", "key": + return field.String() + case "size": + return field.Number(field.NumberFormatInt) + default: + return nil + } +} + +func init() { + // РџРѕ умолчанию без FS + // Если РЅСѓР¶РЅС‹ подписанные URL, Рё загрузка РЅР° FS, РЅСѓР¶РЅРѕ зарегистрировать корректный типа + // РЎРј. cmd/content/command/server.go:195 + field.Register(NewFileType(nil, nil, "")) +} diff --git a/pkg/urlsigner/urlsigner.go b/pkg/urlsigner/urlsigner.go new file mode 100644 index 0000000000000000000000000000000000000000..c5535db0f3ae4f6a2deba7fbb0a1504af6aacf75 --- /dev/null +++ b/pkg/urlsigner/urlsigner.go @@ -0,0 +1,126 @@ +package urlsigner + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "net/url" + "strconv" + "strings" + "time" + + "git.perx.ru/perxis/perxis-go/pkg/data" +) + +type URLSigner interface { + Sign(url *url.URL) *url.URL + Check(url *url.URL) bool +} + +const ( + defaultSignatureExpire = 15 * time.Minute + defaultQueryKey = "sign" + + separator = "|" + saltSize = 16 +) + +type urlSigner struct { + secret string + expirationTime time.Duration + queryKey string + params []string +} + +func NewURLSigner(secret string, expirationTime time.Duration, queryKey string, params ...string) URLSigner { + if len(params) == 0 { + params = make([]string, 0) + } + if expirationTime == 0 { + expirationTime = defaultSignatureExpire + } + if queryKey == "" { + queryKey = defaultQueryKey + } + return &urlSigner{ + secret: secret, + expirationTime: expirationTime, + queryKey: queryKey, + params: params, + } +} + +func (s *urlSigner) Sign(u *url.URL) *url.URL { + + q := u.Query() + + h := sha256.New() + salt := data.GenerateRandomString(saltSize) + for _, p := range s.params { + if vv := q[p]; len(vv) > 0 { + for _, v := range vv { + h.Write([]byte(v)) + } + } + } + + h.Write([]byte(u.Path)) + h.Write([]byte(s.expirationTime.String())) + h.Write([]byte(s.secret)) + h.Write([]byte(salt)) + + expTime := time.Now().Add(s.expirationTime).Unix() + res := strings.Join([]string{strconv.FormatInt(expTime, 16), salt, string(h.Sum(nil))}, separator) + + q.Set(s.queryKey, base64.URLEncoding.EncodeToString([]byte(res))) + u.RawQuery = q.Encode() + return u +} + +func (s *urlSigner) Check(u *url.URL) bool { + + q := u.Query() + sign := q.Get(s.queryKey) + if sign == "" { + return false + } + + b, err := base64.URLEncoding.DecodeString(sign) + if err != nil { + return false + } + + m := bytes.Split(b, []byte(separator)) + if len(m) < 3 { + return false + } + + expTime, err := strconv.ParseInt(string(m[0]), 16, 64) + if err != nil || time.Now().Unix() > expTime { + return false + } + + salt := m[1] + var hash []byte + for i := 2; i < len(m); i++ { + hash = append(hash, m[i]...) + if len(m) > i+1 { + hash = append(hash, []byte(separator)...) + } + } + + h := sha256.New() + for _, p := range s.params { + if vv := q[p]; len(vv) > 0 { + for _, v := range vv { + h.Write([]byte(v)) + } + } + } + h.Write([]byte(u.Path)) + h.Write([]byte(s.expirationTime.String())) + h.Write([]byte(s.secret)) + h.Write(salt) + + return bytes.Equal(hash, h.Sum(nil)) +} diff --git a/pkg/urlsigner/urlsigner_test.go b/pkg/urlsigner/urlsigner_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d4a004bfcfe27cddc8d7aae99c6462669e611e7f --- /dev/null +++ b/pkg/urlsigner/urlsigner_test.go @@ -0,0 +1,92 @@ +package urlsigner + +import ( + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const secret = "secret_key" + +func TestSigner(t *testing.T) { + + t.Run("Not Signed", func(t *testing.T) { + u, _ := url.Parse("http://example.com/path") + + signer := NewURLSigner(secret, time.Minute, "") + require.False(t, signer.Check(u)) + }) + t.Run("Simple", func(t *testing.T) { + u, _ := url.Parse("http://example.com/path") + + signer := NewURLSigner(secret, time.Minute, "") + u = signer.Sign(u) + require.True(t, signer.Check(u)) + }) + t.Run("Custom Query Key", func(t *testing.T) { + u, _ := url.Parse("http://example.com/path") + + signer := NewURLSigner(secret, time.Minute, "custom") + u = signer.Sign(u) + require.True(t, signer.Check(u)) + }) + t.Run("URL Expired", func(t *testing.T) { + u, _ := url.Parse("http://example.com/path") + + signer := NewURLSigner(secret, time.Millisecond, "") + u = signer.Sign(u) + time.Sleep(time.Second) + require.False(t, signer.Check(u)) + }) + + t.Run("Required Params", func(t *testing.T) { + t.Run("Correct", func(t *testing.T) { + u, _ := url.Parse("http://example.com/path?param1=1¶m2=2") + signer := NewURLSigner(secret, time.Minute, "", "param1", "param2") + u = signer.Sign(u) + require.True(t, signer.Check(u)) + }) + t.Run("One Param Empty", func(t *testing.T) { + u, _ := url.Parse("http://example.com/path?param1=1") + signer := NewURLSigner(secret, time.Minute, "", "param1", "param2") + u = signer.Sign(u) + require.True(t, signer.Check(u)) + }) + t.Run("Exchanged Values", func(t *testing.T) { + u, _ := url.Parse("http://example.com/path?param1=1¶m2=2") + signer := NewURLSigner(secret, time.Minute, "", "param1", "param2") + u = signer.Sign(u) + + q := u.Query() + q.Set("param1", "2") + q.Set("param2", "1") + u.RawQuery = q.Encode() + + require.False(t, signer.Check(u)) + }) + }) + t.Run("Extra params", func(t *testing.T) { + u, _ := url.Parse("http://example.com/path?param1=1") + signer := NewURLSigner(secret, time.Minute, "", "param1") + u = signer.Sign(u) + + q := u.Query() + q.Set("extra", "100") + u.RawQuery = q.Encode() + + require.True(t, signer.Check(u)) + }) + t.Run("Array Param Value", func(t *testing.T) { + u, _ := url.Parse("http://example.com/path?param1=1¶m1=2¶m1=3") + signer := NewURLSigner(secret, time.Minute, "", "param1") + u = signer.Sign(u) + + q := u.Query() + q.Set("param1", "1") + u.RawQuery = q.Encode() + + require.False(t, signer.Check(u)) + }) +}