diff --git a/block_types.go b/block_types.go index ad41611..384c8ef 100644 --- a/block_types.go +++ b/block_types.go @@ -17,17 +17,23 @@ type Block struct { type BlockUploadReq struct { AddressID string ShareID string + VolumeID string `json:",omitempty"` LinkID string RevisionID string BlockList []BlockUploadInfo } +type Verifier struct { + Token string +} + type BlockUploadInfo struct { Index int - Size int64 - EncSignature string - Hash string + Verifier *Verifier `json:",omitempty"` + Size int64 `json:",omitempty"` + EncSignature string `json:",omitempty"` + Hash string `json:",omitempty"` } type BlockUploadLink struct { diff --git a/link.go b/link.go index 5822256..dbae317 100644 --- a/link.go +++ b/link.go @@ -36,6 +36,20 @@ func (c *Client) MoveLink(ctx context.Context, shareID, linkID string, req MoveL return nil } +func (c *Client) MoveLinkByVolume(ctx context.Context, volumeID, linkID string, req MoveLinkReq) error { + var res struct { + Code int + } + + if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) { + return r.SetResult(&res).SetBody(req).Put("/drive/v2/volumes/" + volumeID + "/links/" + linkID + "/move") + }); err != nil { + return err + } + + return nil +} + func (c *Client) CreateFile(ctx context.Context, shareID string, req CreateFileReq) (CreateFileRes, error) { var res struct { Code int diff --git a/link_file.go b/link_file.go index 173d9b8..a2c849b 100644 --- a/link_file.go +++ b/link_file.go @@ -89,3 +89,23 @@ func (c *Client) CreateRevision(ctx context.Context, shareID, linkID string) (Cr return res.Revision, nil } + +func (c *Client) GetRevisionVerification(ctx context.Context, shareID, linkID, revisionID string) (RevisionVerificationRes, error) { + var res struct { + VerificationCode string + ContentKeyPacket string + } + + if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) { + return r. + SetResult(&res). + Get("/drive/shares/" + shareID + "/links/" + linkID + "/revisions/" + revisionID + "/verification") + }); err != nil { + return RevisionVerificationRes{}, err + } + + return RevisionVerificationRes{ + VerificationCode: res.VerificationCode, + ContentKeyPacket: res.ContentKeyPacket, + }, nil +} diff --git a/link_file_types.go b/link_file_types.go index a06a6b4..27a5f63 100644 --- a/link_file_types.go +++ b/link_file_types.go @@ -40,13 +40,17 @@ func GetNameHash(name string, hashKey []byte) (string, error) { type MoveLinkReq struct { ParentLinkID string - Name string // Encrypted File Name - OriginalHash string // Old Encrypted File Name Hash - Hash string // Encrypted File Name Hash by using parent's NodeHashKey + Name string // Encrypted File Name + NameSignatureEmail string `json:",omitempty"` + OriginalHash string // Old Encrypted File Name Hash + Hash string // Encrypted File Name Hash by using parent's NodeHashKey + ContentHash *string `json:",omitempty"` + NodePassphrase string // The passphrase used to unlock the NodeKey, encrypted by the owning Link/Share keyring. - NodePassphraseSignature string // The signature of the NodePassphrase + NodePassphraseSignature string `json:",omitempty"` // The signature of the NodePassphrase + SignatureEmail string `json:",omitempty"` - SignatureAddress string // Signature email address used to sign passphrase and name + SignatureAddress string `json:",omitempty"` // Signature email address used to sign passphrase and name } func (moveLinkReq *MoveLinkReq) SetName(name string, addrKR, nodeKR *crypto.KeyRing) error { @@ -142,6 +146,11 @@ type CreateRevisionRes struct { ID string // Encrypted Revision ID } +type RevisionVerificationRes struct { + VerificationCode string + ContentKeyPacket string +} + type CommitRevisionReq struct { ManifestSignature string SignatureAddress string diff --git a/link_folder.go b/link_folder.go index 7e403a0..a57ff5d 100644 --- a/link_folder.go +++ b/link_folder.go @@ -9,6 +9,10 @@ import ( "github.com/go-resty/resty/v2" ) +func isDeleteChildrenResponseCodeAllowed(code Code) bool { + return code == SuccessCode || code == AFileOrFolderNotFound +} + func (c *Client) ListChildren(ctx context.Context, shareID, linkID string, showAll bool) ([]Link, error) { var res struct { Links []Link @@ -107,7 +111,7 @@ func (c *Client) DeleteChildren(ctx context.Context, shareID, linkID string, chi } for _, res := range res.Responses { - if res.Response.Code != SuccessCode { + if !isDeleteChildrenResponseCodeAllowed(res.Response.Code) { return fmt.Errorf("failed to delete child: %w", res.Response) } } diff --git a/link_folder_test.go b/link_folder_test.go new file mode 100644 index 0000000..252635b --- /dev/null +++ b/link_folder_test.go @@ -0,0 +1,24 @@ +package proton + +import "testing" + +func TestIsDeleteChildrenResponseCodeAllowed(t *testing.T) { + tests := []struct { + name string + code Code + want bool + }{ + {name: "success", code: SuccessCode, want: true}, + {name: "not_found", code: 2501, want: true}, + {name: "conflict", code: AFileOrFolderNameExist, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isDeleteChildrenResponseCodeAllowed(tt.code) + if got != tt.want { + t.Fatalf("unexpected result: got=%v want=%v", got, tt.want) + } + }) + } +} diff --git a/manager_builder.go b/manager_builder.go index 2cb22cc..eff0d6b 100644 --- a/manager_builder.go +++ b/manager_builder.go @@ -22,30 +22,32 @@ const ( ) type managerBuilder struct { - hostURL string - appVersion string - userAgent string - transport http.RoundTripper - verifyProofs bool - cookieJar http.CookieJar - retryCount int - logger resty.Logger - debug bool - panicHandler async.PanicHandler + hostURL string + appVersion string + driveSDKVersion string + userAgent string + transport http.RoundTripper + verifyProofs bool + cookieJar http.CookieJar + retryCount int + logger resty.Logger + debug bool + panicHandler async.PanicHandler } func newManagerBuilder() *managerBuilder { return &managerBuilder{ - hostURL: DefaultHostURL, - appVersion: DefaultAppVersion, - userAgent: DefaultUserAgent, - transport: http.DefaultTransport, - verifyProofs: true, - cookieJar: nil, - retryCount: 3, - logger: nil, - debug: false, - panicHandler: async.NoopPanicHandler{}, + hostURL: DefaultHostURL, + appVersion: DefaultAppVersion, + driveSDKVersion: "", + userAgent: DefaultUserAgent, + transport: http.DefaultTransport, + verifyProofs: true, + cookieJar: nil, + retryCount: 3, + logger: nil, + debug: false, + panicHandler: async.NoopPanicHandler{}, } } @@ -80,6 +82,9 @@ func (builder *managerBuilder) build() *Manager { // Set app version in header. m.rc.OnBeforeRequest(func(_ *resty.Client, req *resty.Request) error { req.SetHeader("x-pm-appversion", builder.appVersion) + if builder.driveSDKVersion != "" { + req.SetHeader("x-pm-drive-sdk-version", builder.driveSDKVersion) + } req.SetHeader("User-Agent", builder.userAgent) return nil }) diff --git a/option.go b/option.go index 5dc3239..37e86a7 100644 --- a/option.go +++ b/option.go @@ -32,6 +32,20 @@ func WithAppVersion(appVersion string) Option { } } +func WithDriveSDKVersion(driveSDKVersion string) Option { + return &withDriveSDKVersion{ + driveSDKVersion: driveSDKVersion, + } +} + +type withDriveSDKVersion struct { + driveSDKVersion string +} + +func (opt withDriveSDKVersion) config(builder *managerBuilder) { + builder.driveSDKVersion = opt.driveSDKVersion +} + type withUserAgent struct { userAgent string } diff --git a/response.go b/response.go index 0ce36d7..5183c25 100644 --- a/response.go +++ b/response.go @@ -34,6 +34,7 @@ const ( // ProtonDrive AFileOrFolderNameExist Code = 2500 ADraftExist Code = 2500 + AFileOrFolderNotFound Code = 2501 ) var ( diff --git a/server/proxy.go b/server/proxy.go index e012fbd..a83b200 100644 --- a/server/proxy.go +++ b/server/proxy.go @@ -75,7 +75,9 @@ func (s *Server) handleProxyAuthPost(w http.ResponseWriter, r *http.Request, pro return } - if info, ok := s.authCacher.GetAuth(req.Username); ok { + cacheKey := authCacheKeyFor(s.authCacher, req.Username, r) + + if info, ok := s.authCacher.GetAuth(cacheKey); ok { if err := writeBody(w, info); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -93,7 +95,7 @@ func (s *Server) handleProxyAuthPost(w http.ResponseWriter, r *http.Request, pro return } - s.authCacher.SetAuth(req.Username, res) + s.authCacher.SetAuth(cacheKey, res) } } @@ -109,7 +111,9 @@ func (s *Server) handleProxyAuthInfo(proxier func(string) HandlerFunc) http.Hand return } - if info, ok := s.authCacher.GetAuthInfo(req.Username); ok { + cacheKey := authCacheKeyFor(s.authCacher, req.Username, r) + + if info, ok := s.authCacher.GetAuthInfo(cacheKey); ok { if err := writeBody(w, info); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -127,11 +131,27 @@ func (s *Server) handleProxyAuthInfo(proxier func(string) HandlerFunc) http.Hand return } - s.authCacher.SetAuthInfo(req.Username, res) + s.authCacher.SetAuthInfo(cacheKey, res) } } } +func authCacheKey(username string, r *http.Request) string { + return strings.Join([]string{ + username, + r.Header.Get("x-pm-appversion"), + r.Header.Get("x-pm-drive-sdk-version"), + }, "\x00") +} + +func authCacheKeyFor(cacher AuthCacher, username string, r *http.Request) string { + if _, ok := cacher.(*authCache); ok { + return authCacheKey(username, r) + } + + return username +} + type HandlerFunc func(http.ResponseWriter, *http.Request) ([]byte, error) type proxyServer struct { diff --git a/server/server_test.go b/server/server_test.go index f04470a..1148e15 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1428,6 +1428,188 @@ func TestServer_Proxy_Cache(t *testing.T) { }) } +func TestServer_Proxy_Cache_DoesNotReuseAuthAcrossAppVersions(t *testing.T) { + withServer(t, func(ctx context.Context, s *Server, _ *proton.Manager) { + _, _, err := s.CreateUser("user", []byte("pass")) + require.NoError(t, err) + + proxy := New( + WithProxyOrigin(s.GetHostURL()), + WithProxyTransport(proton.InsecureTransport()), + WithAuthCacher(NewAuthCache()), + ) + defer proxy.Close() + + var authCalls []Call + var authInfoCalls []Call + + s.AddCallWatcher(func(call Call) { + authCalls = append(authCalls, call) + }, "/auth/v4") + s.AddCallWatcher(func(call Call) { + authInfoCalls = append(authInfoCalls, call) + }, "/auth/v4/info") + + newManager := func(appVersion string) *proton.Manager { + return proton.New( + proton.WithHostURL(proxy.GetProxyURL()), + proton.WithAppVersion(appVersion), + proton.WithTransport(proton.InsecureTransport()), + proton.WithSkipVerifyProofs(), + ) + } + + m1 := newManager("web-account@5.0.345.1") + defer m1.Close() + + c1, auth1, err := m1.NewClientWithLogin(ctx, "user", []byte("pass")) + require.NoError(t, err) + defer c1.Close() + + m2 := newManager("web-drive@5.2.0+af66c8fa") + defer m2.Close() + + c2, auth2, err := m2.NewClientWithLogin(ctx, "user", []byte("pass")) + require.NoError(t, err) + defer c2.Close() + + require.NotEqual(t, auth1.UID, auth2.UID) + require.Len(t, authCalls, 2) + require.Len(t, authInfoCalls, 2) + require.Equal(t, "web-account@5.0.345.1", authCalls[0].RequestHeader.Get("x-pm-appversion")) + require.Equal(t, "web-drive@5.2.0+af66c8fa", authCalls[1].RequestHeader.Get("x-pm-appversion")) + }) +} + +func TestServer_Proxy_Cache_DoesNotReuseAuthAcrossDriveSDKVersions(t *testing.T) { + withServer(t, func(ctx context.Context, s *Server, _ *proton.Manager) { + _, _, err := s.CreateUser("user", []byte("pass")) + require.NoError(t, err) + + proxy := New( + WithProxyOrigin(s.GetHostURL()), + WithProxyTransport(proton.InsecureTransport()), + WithAuthCacher(NewAuthCache()), + ) + defer proxy.Close() + + var authCalls []Call + var authInfoCalls []Call + + s.AddCallWatcher(func(call Call) { + authCalls = append(authCalls, call) + }, "/auth/v4") + s.AddCallWatcher(func(call Call) { + authInfoCalls = append(authInfoCalls, call) + }, "/auth/v4/info") + + newManager := func(driveSDKVersion string) *proton.Manager { + return proton.New( + proton.WithHostURL(proxy.GetProxyURL()), + proton.WithAppVersion("web-drive@5.2.0+af66c8fa"), + proton.WithDriveSDKVersion(driveSDKVersion), + proton.WithTransport(proton.InsecureTransport()), + proton.WithSkipVerifyProofs(), + ) + } + + m1 := newManager("js@5.2.0+af66c8fa") + defer m1.Close() + + c1, auth1, err := m1.NewClientWithLogin(ctx, "user", []byte("pass")) + require.NoError(t, err) + defer c1.Close() + + m2 := newManager("js@5.2.1+af66c8fa") + defer m2.Close() + + c2, auth2, err := m2.NewClientWithLogin(ctx, "user", []byte("pass")) + require.NoError(t, err) + defer c2.Close() + + require.NotEqual(t, auth1.UID, auth2.UID) + require.Len(t, authCalls, 2) + require.Len(t, authInfoCalls, 2) + require.Equal(t, "js@5.2.0+af66c8fa", authCalls[0].RequestHeader.Get("x-pm-drive-sdk-version")) + require.Equal(t, "js@5.2.1+af66c8fa", authCalls[1].RequestHeader.Get("x-pm-drive-sdk-version")) + require.Equal(t, "js@5.2.0+af66c8fa", authInfoCalls[0].RequestHeader.Get("x-pm-drive-sdk-version")) + require.Equal(t, "js@5.2.1+af66c8fa", authInfoCalls[1].RequestHeader.Get("x-pm-drive-sdk-version")) + }) +} + +type recordingAuthCache struct { + authInfoKeys []string + authKeys []string + info map[string]proton.AuthInfo + auth map[string]proton.Auth +} + +func newRecordingAuthCache() *recordingAuthCache { + return &recordingAuthCache{ + info: make(map[string]proton.AuthInfo), + auth: make(map[string]proton.Auth), + } +} + +func (c *recordingAuthCache) GetAuthInfo(username string) (proton.AuthInfo, bool) { + c.authInfoKeys = append(c.authInfoKeys, username) + info, ok := c.info[username] + return info, ok +} + +func (c *recordingAuthCache) SetAuthInfo(username string, info proton.AuthInfo) { + c.authInfoKeys = append(c.authInfoKeys, username) + c.info[username] = info +} + +func (c *recordingAuthCache) GetAuth(username string) (proton.Auth, bool) { + c.authKeys = append(c.authKeys, username) + auth, ok := c.auth[username] + return auth, ok +} + +func (c *recordingAuthCache) SetAuth(username string, auth proton.Auth) { + c.authKeys = append(c.authKeys, username) + c.auth[username] = auth +} + +func TestServer_Proxy_Cache_PreservesCustomAuthCacherUsernameKeys(t *testing.T) { + withServer(t, func(ctx context.Context, s *Server, _ *proton.Manager) { + _, _, err := s.CreateUser("user", []byte("pass")) + require.NoError(t, err) + + cache := newRecordingAuthCache() + proxy := New( + WithProxyOrigin(s.GetHostURL()), + WithProxyTransport(proton.InsecureTransport()), + WithAuthCacher(cache), + ) + defer proxy.Close() + + m := proton.New( + proton.WithHostURL(proxy.GetProxyURL()), + proton.WithAppVersion("web-drive@5.2.0+af66c8fa"), + proton.WithDriveSDKVersion("js@5.2.0+af66c8fa"), + proton.WithTransport(proton.InsecureTransport()), + proton.WithSkipVerifyProofs(), + ) + defer m.Close() + + c, _, err := m.NewClientWithLogin(ctx, "user", []byte("pass")) + require.NoError(t, err) + defer c.Close() + + require.NotEmpty(t, cache.authInfoKeys) + require.NotEmpty(t, cache.authKeys) + for _, key := range cache.authInfoKeys { + require.Equal(t, "user", key) + } + for _, key := range cache.authKeys { + require.Equal(t, "user", key) + } + }) +} + func TestServer_Proxy_AuthDelete(t *testing.T) { withServer(t, func(ctx context.Context, s *Server, m *proton.Manager) { withUser(ctx, t, s, m, "user", "pass", func(_ *proton.Client) { @@ -1492,6 +1674,8 @@ func TestServer_RealProxy(t *testing.T) { m := proton.New( proton.WithHostURL(proxy.GetProxyURL()), + proton.WithAppVersion("external-drive-rclone@1.73.1"), + proton.WithDriveSDKVersion("js@5.2.0+af66c8fa"), proton.WithTransport(proton.InsecureTransport()), ) defer m.Close() @@ -1523,6 +1707,8 @@ func TestServer_RealProxy_Cache(t *testing.T) { m := proton.New( proton.WithHostURL(proxy.GetProxyURL()), + proton.WithAppVersion("external-drive-rclone@1.73.1"), + proton.WithDriveSDKVersion("js@5.2.0+af66c8fa"), proton.WithTransport(proton.InsecureTransport()), proton.WithSkipVerifyProofs(), )