Initial commit

This commit is contained in:
Chun-Hung Tseng
2023-06-20 17:10:43 +02:00
parent 944c82c536
commit 257ea3c1fe
29 changed files with 2863 additions and 0 deletions

28
.github/workflows/check.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Lint and Test
on: push
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Get sources
uses: actions/checkout@v3
- name: Set up Go 1.18
uses: actions/setup-go@v3
with:
go-version: '1.18'
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.50.0
args: --timeout=180s
skip-cache: true
# - name: Run tests
# run: go test -v ./...
# - name: Run tests with race check
# run: go test -v -race ./...

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.DS_Store
.credential
.*.credential
data
config.toml

31
Documentation.md Normal file
View File

@@ -0,0 +1,31 @@
# Documentation
Since the Proton API isn't open sourced, this document serves as the team's understanding for future reference.
# Proton Drive API
## Terminology
### Volume
### Share
### Node
### Link
## Encryption
Encryption, decryption, and signature signing and verification, etc. are all performed by using the go-crypto library.
### Login
Proton uses SRP for logging in the users. After logging in, there is a small time window (several minutes) where users can access certain routes, which is in the `scope` field, e.g. getting user salt.
Since the user and address key rings are encrypted with passphrase tied to salt and user password, we need to cache this information as soon as the first log in happens for future usage.
### User Key
### Address Key
### Node/Link Key

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2023 Chun-Hung Tseng
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

133
README.md
View File

@@ -1,2 +1,135 @@
# Proton API Bridge
Thanks to Proton open sourcing [proton-go-api](https://github.com/ProtonMail/go-proton-api) and the web, iOS, and Android client codebases, we don't need to completely reverse engineer the APIs by observing the web client traffic!
[proton-go-api](https://github.com/ProtonMail/go-proton-api) provides the basic building blocks of API calls and error handling, such as 429 exponential back-off, but it is pretty much just a barebone interface to the Proton API. For example, the encryption and decryption of the Proton Drive file are not provided in this library.
This codebase, Proton API Bridge, bridges the gap, so software like [rclone](https://github.com/rclone/rclone) can be built on top of this quickly. This codebase handles the intricate tasks before and after calling Proton APIs, particularly the complex encryption scheme, allowing developers to implement features for other software on top of this codebase.
Currently, only Proton Drive APIs are bridged, as we are aiming to implement a backend for rclone.
## Sidenotes
We are using a fork of the [proton-go-api](https://github.com/henrybear327/go-proton-api), as we are adding quite some new code to it. We are actively rebasing on top of the master branch of the upstream, as we will try to commit back to the upstream once we feel like the code changes are stable.
# Unit testing and linting
`golangci-lint run && go test -race -failfast -v ./...`
# Drive APIs
> In collaboration with Azimjon Pulatov, in memory of our good old days at Meta, London, in the summer of 2022.
Currently, the development are split into 2 versions.
V1 supports the features [required by rclone](https://github.com/henrybear327/rclone/blob/master/fs/types.go), such as `file listing`. As the unit and integration tests from rclone have all been passed, we would stabilize this and then move onto developing V2.
V2 will bring in optimizations and enhancements, such as optimizing uploading and downloading performance, supporting thumbnails, etc.
## V1
### Features
- [x] Log in to an account without 2FA using username and password
- [x] Obtain keyring
- [x] Cache access token, etc. to be able to reuse the session
- [x] Bug: 403: Access token does not have sufficient scope - used the wrong newClient function
- [x] Volume actions
- [x] List all volumes
- [x] Share actions
- [x] Get all shares
- [x] Get default share
- [x] Fix context with proper propagation instead of using `ctx` everywhere
- [x] Folder actions
- [x] List all folders and files within the root folder
- [x] BUG: listing directory - missing signature when there are more than 1 share -> we need to check for the "active" folder type first
- [x] List all folders and files recursively within the root folder
- [x] Delete
- [x] Create
- [x] File actions
- [x] Download
- [x] Download empty file
- [x] Properly handle large files and empty files (check iOS codebase)
- esp. large files, where buffering in-memory will screw up the runtime
- [x] Check signature and hash
- [x] Delete
- [x] Upload
- [x] Handle empty file
- [x] Parse mime type
- [x] Add revision
- [x] Modified time
- [x] List file metadata
- [x] Duplicated file name handling: 422: A file or folder with that name already exists (Code=2500, Status=422)
- [x] Init ProtonDrive with config passed in as Map
- [x] Remove all `log.Fatalln` and use proper error propagation (basically remove `HandleError` and we go from there)
- [x] Integration tests
- [x] Remove drive demo code
- [x] Create a Drive struct to encapsulate all the functions (maybe?)
- [x] Move comments to proper places
- [x] Modify `shouldRejectDestructiveActions()`
- [x] Refactor
- [x] Reduce config options on caching access token
- [x] Remove integration test safeguarding
### TODO
- [x] address go dependencies
- Fixed by doing the following in the `go-proton-api` repo to bump to use the latest commit
- `go get github.com/ProtonMail/go-proton-api@ea8de5f674b7f9b0cca8e3a5076ffe3c5a867e01`
- `go get github.com/ProtonMail/gluon@fb7689b15ae39c3efec3ff3c615c3d2dac41cec8`
- [x] Remove mail-related apis (to reduce dependencies)
- [x] Make a "super class" and expose all necessary methods for the outside to call
- [x] Add 2FA login
- [x] Fix the function argument passing (using pointers)
- [x] Handle account with
- [x] multiple addresses
- [x] multiple keys per addresses
- [x] Update RClone's contribution.md file
- [x] Remove delete all's hardcoded string
- [x] Point to the right proton-go-api branch
- [x] Run `go get github.com/henrybear327/go-proton-api@dev` to update go mod
### Known limitations
- Large file handling: for uploading, files will be loaded into the memory entirely, encrypted, and then chunked; for downloading, the file will be written when all blocks are decrypted and checked
- Crypto-related operations, e.g. signature verification, still needs to cross check with iOS or web open source codebase
- No thumbnails, respecting accepted MIME types, max upload size, can't init Proton Drive, etc.
- Assumptions
- only one main share per account
- only operate on active links
## V2
- [ ] Improve file searching function to use HMAC instead of just using string comparison
- [ ] Remove e.g. proton.link related exposures in the function signature (this library should abstract them all)
- [ ] Documentation
- [ ] Go through Drive iOS source code and check the logic control flow
- [ ] Use proper AppVersion (we need to be friendly to the Proton servers)
- [ ] Figure out the bottleneck by doing some profiling
- [ ] Proper error handling by looking at the return code instead of the error string
- [ ] Duplicated folder name handling: 422: A file or folder with that name already exists (Code=2500, Status=422)
- [ ] Not found: ERROR RESTY 422: File or folder was not found. (Code=2501, Status=422), Attempt 1
- [ ] Failed upload: Draft already exists on this revision (Code=2500, Status=409)
- [ ] File
- [ ] Improve large file handling
- [ ] Handle failed / interrupted upload
- [ ] [Filename encoding](https://github.com/ProtonMail/WebClients/blob/b4eba99d241af4fdae06ff7138bd651a40ef5d3c/applications/drive/src/app/store/_links/validation.ts#L51)
- [ ] Folder
- [ ] (Feature) Update (force overwrite)
- [ ] (Feature) Move
- [ ] Commit back to proton-go-api and switch to using upstream (make sure the tag is at the tip though)
- [ ] Support legacy 2-password mode
- [ ] Support thumbnail
- [ ] Proton Drive init (no prior Proton Drive login before -> probably will have no key, volume, etc. to start with at all)
- [ ] linkID caching -> would need to listen to the event api though
- [ ] Integration tests
- [ ] Check file metadata
- [ ] Try to check if all functions are used at least once so we know if it's functioning or not
- [ ] Handle accounts with multiple shares
- [ ] Use CI to run integration tests
- [ ] Some error handling from [here](https://github.com/ProtonMail/WebClients/blob/main/packages/shared/lib/drive/constants.ts) MAX_NAME_LENGTH, TIMEOUT
- [ ] [Mimetype restrictions](https://github.com/ProtonMail/WebClients/blob/main/packages/shared/lib/drive/constants.ts#LL47C14-L47C42)
- [ ] Address TODO and FIXME
# Questions
- [x] rclone's folder / file rename detection? -> just implement the interface and rclone will deal with the rest!
- [ ] How often will we run into 429 on login

96
common/config.go Normal file
View File

@@ -0,0 +1,96 @@
package common
import "os"
type Config struct {
/* Login */
FirstLoginCredential *FirstLoginCredentialData
ReusableCredential *ReusableCredentialData
UseReusableLogin bool
CredentialCacheFile string // If CredentialCacheFile is empty, no credential will be logged
RefreshAccessToken bool
/* Setting */
DestructiveIntegrationTest bool // CAUTION: the integration test requires a clean proton drive
EmptyTrashAfterIntegrationTest bool // CAUTION: the integration test will clean up all the data in the trash
/* Drive */
DataFolderName string
}
type FirstLoginCredentialData struct {
Username string
Password string
TwoFA string
}
type ReusableCredentialData struct {
UID string
AccessToken string
RefreshToken string
SaltedKeyPass string // []byte <-> base64
}
func NewConfigWithDefaultValues() *Config {
return &Config{
// login
FirstLoginCredential: &FirstLoginCredentialData{
Username: "",
Password: "",
TwoFA: "",
},
ReusableCredential: &ReusableCredentialData{
UID: "",
AccessToken: "",
RefreshToken: "",
SaltedKeyPass: "", // []byte <-> base64
},
UseReusableLogin: false,
CredentialCacheFile: "",
RefreshAccessToken: false,
DestructiveIntegrationTest: false,
EmptyTrashAfterIntegrationTest: false,
DataFolderName: "data",
}
}
func NewConfigForIntegrationTests() *Config {
username := os.Getenv("PROTON_API_BRIDGE_TEST_USERNAME")
password := os.Getenv("PROTON_API_BRIDGE_TEST_PASSWORD")
twoFA := os.Getenv("PROTON_API_BRIDGE_TEST_TWOFA")
useReusableLoginStr := os.Getenv("PROTON_API_BRIDGE_TEST_USE_REUSABLE_LOGIN")
useReusableLogin := false
if useReusableLoginStr == "1" {
useReusableLogin = true
}
uid := os.Getenv("PROTON_API_BRIDGE_TEST_UID")
accessToken := os.Getenv("PROTON_API_BRIDGE_TEST_ACCESS_TOKEN")
refreshToken := os.Getenv("PROTON_API_BRIDGE_TEST_REFRESH_TOKEN")
saltedKeyPass := os.Getenv("PROTON_API_BRIDGE_TEST_SALTEDKEYPASS")
return &Config{
FirstLoginCredential: &FirstLoginCredentialData{
Username: username,
Password: password,
TwoFA: twoFA,
},
ReusableCredential: &ReusableCredentialData{
UID: uid,
AccessToken: accessToken,
RefreshToken: refreshToken,
SaltedKeyPass: saltedKeyPass, // []byte <-> base64
},
UseReusableLogin: useReusableLogin,
CredentialCacheFile: ".credential",
RefreshAccessToken: false,
DestructiveIntegrationTest: true,
EmptyTrashAfterIntegrationTest: true,
DataFolderName: "data",
}
}

11
common/error.go Normal file
View File

@@ -0,0 +1,11 @@
package common
import "errors"
var (
ErrKeyPassOrSaltedKeyPassMustBeNotNil = errors.New("either keyPass or saltedKeyPass must be not nil")
ErrFailedToUnlockUserKeys = errors.New("failed to unlock user keys")
ErrUsernameAndPasswordRequired = errors.New("username and password are required")
Err2FACodeRequired = errors.New("this account requires a 2FA code")
)

70
common/keyring.go Normal file
View File

@@ -0,0 +1,70 @@
package common
import (
"context"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/henrybear327/go-proton-api"
)
/*
The Proton account keys are organized in the following hierarchy.
An account has some users, each of the user will have one or more user keys.
Each of the user will have some addresses, each of the address will have one or more address keys.
A key is encrypted by a passphrase, and the passphrase is encrypted by another key.
The address keyrings are encrypted with the primary user keyring at the time.
The primary address key is used to create (encrypt) and retrieve (decrypt) data, e.g. shares
*/
func getAccountKRs(ctx context.Context, c *proton.Client, keyPass, saltedKeyPass []byte) (*crypto.KeyRing, map[string]*crypto.KeyRing, []proton.Address, []byte, error) {
/* Code taken and modified from proton-bridge */
user, err := c.GetUser(ctx)
if err != nil {
return nil, nil, nil, nil, err
}
// log.Printf("user %#v", user)
addr, err := c.GetAddresses(ctx)
if err != nil {
return nil, nil, nil, nil, err
}
// log.Printf("addr %#v", addr)
if saltedKeyPass == nil {
if keyPass == nil {
return nil, nil, nil, nil, ErrKeyPassOrSaltedKeyPassMustBeNotNil
}
/*
Notes for -> BUG: Access token does not have sufficient scope
Only within the first x minutes that the user logs in with username and password, the getSalts route will be available to be called!
*/
salts, err := c.GetSalts(ctx)
if err != nil {
return nil, nil, nil, nil, err
}
// log.Printf("salts %#v", salts)
saltedKeyPass, err = salts.SaltForKey(keyPass, user.Keys.Primary().ID)
if err != nil {
return nil, nil, nil, nil, err
}
// log.Printf("saltedKeyPass ok")
}
userKR, addrKRs, err := proton.Unlock(user, addr, saltedKeyPass, nil)
if err != nil {
return nil, nil, nil, nil, err
} else if userKR.CountDecryptionEntities() == 0 {
if err != nil {
return nil, nil, nil, nil, ErrFailedToUnlockUserKeys
}
}
return userKR, addrKRs, addr, saltedKeyPass, nil
}

28
common/proton_manager.go Normal file
View File

@@ -0,0 +1,28 @@
package common
import (
"github.com/henrybear327/go-proton-api"
)
// TODO: use proper appname and version
func AppVersion() string {
// return "web-drive@5.0.13.8"
return "ios-drive@1.14.0"
}
func defaultAPIOptions() []proton.Option {
return []proton.Option{
proton.WithAppVersion(AppVersion()),
}
}
func getProtonManager() *proton.Manager {
/*
Notes on API calls
If the app version is not specified, the api calls will be rejected.
*/
m := proton.New(defaultAPIOptions()...)
return m
}

156
common/user.go Normal file
View File

@@ -0,0 +1,156 @@
package common
import (
"context"
"encoding/base64"
"encoding/json"
"log"
"os"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/henrybear327/go-proton-api"
)
func cacheCredentialToFile(config *Config) error {
if config.CredentialCacheFile != "" {
str, err := json.Marshal(config.ReusableCredential)
if err != nil {
return err
}
file, err := os.Create(config.CredentialCacheFile)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString(string(str))
if err != nil {
return err
}
}
return nil
}
/*
Log in methods
- username and password to log in
- UID and refresh token
Keyring decryption
The password will be salted, and then used to decrypt the keyring. The salted password needs to be and can be cached, so the keyring can be re-decrypted when needed
*/
func Login(ctx context.Context, config *Config) (*proton.Manager, *proton.Client, *crypto.KeyRing, map[string]*crypto.KeyRing, []proton.Address, error) {
var c *proton.Client
var auth proton.Auth
var userKR *crypto.KeyRing
var addrKRs map[string]*crypto.KeyRing
var addr []proton.Address
// get manager
m := getProtonManager()
if config.UseReusableLogin {
/*
Using NewClientWithRefresh so the credential can last longer,
as each run of the program will trigger a access token refresh
*/
var err error
if config.RefreshAccessToken {
c, auth, err = m.NewClientWithRefresh(ctx, config.ReusableCredential.UID, config.ReusableCredential.RefreshToken)
if err != nil {
return nil, nil, nil, nil, nil, err
}
config.ReusableCredential.UID = auth.UID
config.ReusableCredential.AccessToken = auth.AccessToken
config.ReusableCredential.RefreshToken = auth.RefreshToken
} else {
c = m.NewClient(config.ReusableCredential.UID, config.ReusableCredential.AccessToken, config.ReusableCredential.RefreshToken)
}
err = cacheCredentialToFile(config)
if err != nil {
return nil, nil, nil, nil, nil, err
}
SaltedKeyPassByteArr, err := base64.StdEncoding.DecodeString(config.ReusableCredential.SaltedKeyPass)
if err != nil {
return nil, nil, nil, nil, nil, err
}
userKR, addrKRs, addr, _, err = getAccountKRs(ctx, c, nil, SaltedKeyPassByteArr)
if err != nil {
return nil, nil, nil, nil, nil, err
}
} else {
username := config.FirstLoginCredential.Username
password := config.FirstLoginCredential.Password
if username == "" || password == "" {
return nil, nil, nil, nil, nil, ErrUsernameAndPasswordRequired
}
// perform login
var err error
c, auth, err = m.NewClientWithLogin(ctx, username, []byte(password))
if err != nil {
return nil, nil, nil, nil, nil, err
}
if auth.TwoFA.Enabled&proton.HasTOTP != 0 {
if config.FirstLoginCredential.TwoFA != "" {
err := c.Auth2FA(ctx, proton.Auth2FAReq{
TwoFactorCode: config.FirstLoginCredential.TwoFA,
})
if err != nil {
return nil, nil, nil, nil, nil, err
}
} else {
return nil, nil, nil, nil, nil, Err2FACodeRequired
}
}
// decrypt keyring
var saltedKeyPassByteArr []byte
userKR, addrKRs, addr, saltedKeyPassByteArr, err = getAccountKRs(ctx, c, []byte(password), nil)
if err != nil {
return nil, nil, nil, nil, nil, err
}
saltedKeyPass := base64.StdEncoding.EncodeToString(saltedKeyPassByteArr)
config.ReusableCredential.UID = auth.UID
config.ReusableCredential.AccessToken = auth.AccessToken
config.ReusableCredential.RefreshToken = auth.RefreshToken
config.ReusableCredential.SaltedKeyPass = saltedKeyPass
err = cacheCredentialToFile(config)
if err != nil {
return nil, nil, nil, nil, nil, err
}
}
return m, c, userKR, addrKRs, addr, nil
}
func Logout(ctx context.Context, config *Config, m *proton.Manager, c *proton.Client, userKR *crypto.KeyRing, addrKRs map[string]*crypto.KeyRing) error {
defer m.Close()
defer c.Close()
if config.CredentialCacheFile == "" {
log.Println("Logging out user")
// log out
err := c.AuthDelete(ctx)
if err != nil {
return err
}
// clear keyrings
userKR.ClearPrivateParams()
for i := range addrKRs {
addrKRs[i].ClearPrivateParams()
}
}
return nil
}

5
constants.go Normal file
View File

@@ -0,0 +1,5 @@
package proton_api_bridge
var (
UPLOAD_BLOCK_SIZE = 4 * 1024 * 1024
)

168
crypto.go Normal file
View File

@@ -0,0 +1,168 @@
package proton_api_bridge
import (
"crypto/sha256"
"encoding/base64"
"io"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/gopenpgp/v2/helper"
)
func generatePassphrase() (string, error) {
token, err := crypto.RandomToken(32)
if err != nil {
return "", err
}
tokenBase64 := base64.StdEncoding.EncodeToString(token)
return tokenBase64, nil
}
func generateCryptoKey() (string, string, error) {
passphrase, err := generatePassphrase()
if err != nil {
return "", "", err
}
// all hardcoded values from iOS drive
key, err := helper.GenerateKey("Drive key", "noreply@protonmail.com", []byte(passphrase), "x25519", 0)
if err != nil {
return "", "", err
}
return passphrase, key, nil
}
// taken from Proton Go API Backend
func encryptWithSignature(kr, addrKR *crypto.KeyRing, b []byte) (string, string, error) {
enc, err := kr.Encrypt(crypto.NewPlainMessage(b), nil)
if err != nil {
return "", "", err
}
encArm, err := enc.GetArmored()
if err != nil {
return "", "", err
}
sig, err := addrKR.SignDetached(crypto.NewPlainMessage(b))
if err != nil {
return "", "", err
}
sigArm, err := sig.GetArmored()
if err != nil {
return "", "", err
}
return encArm, sigArm, nil
}
func generateNodeKeys(kr, addrKR *crypto.KeyRing) (string, string, string, error) {
nodePassphrase, nodeKey, err := generateCryptoKey()
if err != nil {
return "", "", "", err
}
nodePassphraseEnc, nodePassphraseSignature, err := encryptWithSignature(kr, addrKR, []byte(nodePassphrase))
if err != nil {
return "", "", "", err
}
return nodeKey, nodePassphraseEnc, nodePassphraseSignature, nil
}
func reencryptKeyPacket(srcKR, dstKR, addrKR *crypto.KeyRing, passphrase string) (string, error) {
oldSplitMessage, err := crypto.NewPGPSplitMessageFromArmored(passphrase)
if err != nil {
return "", err
}
sessionKey, err := srcKR.DecryptSessionKey(oldSplitMessage.KeyPacket)
if err != nil {
return "", err
}
newKeyPacket, err := dstKR.EncryptSessionKey(sessionKey)
if err != nil {
return "", err
}
newSplitMessage := crypto.NewPGPSplitMessage(newKeyPacket, oldSplitMessage.DataPacket)
return newSplitMessage.GetArmored()
}
func getKeyRing(kr, addrKR *crypto.KeyRing, key, passphrase, passphraseSignature string) (*crypto.KeyRing, error) {
enc, err := crypto.NewPGPMessageFromArmored(passphrase)
if err != nil {
return nil, err
}
dec, err := kr.Decrypt(enc, nil, crypto.GetUnixTime())
if err != nil {
return nil, err
}
sig, err := crypto.NewPGPSignatureFromArmored(passphraseSignature)
if err != nil {
return nil, err
}
if err := addrKR.VerifyDetached(dec, sig, crypto.GetUnixTime()); err != nil {
return nil, err
}
lockedKey, err := crypto.NewKeyFromArmored(key)
if err != nil {
return nil, err
}
unlockedKey, err := lockedKey.Unlock(dec.GetBinary())
if err != nil {
return nil, err
}
return crypto.NewKeyRing(unlockedKey)
}
func decryptBlockIntoBuffer(sessionKey *crypto.SessionKey, addrKR, nodeKR *crypto.KeyRing, originalHash, encSignature string, buffer io.ReaderFrom, block io.ReadCloser) error {
data, err := io.ReadAll(block)
if err != nil {
return err
}
plainMessage, err := sessionKey.Decrypt(data)
if err != nil {
return err
}
encSignatureArm, err := crypto.NewPGPMessageFromArmored(encSignature)
if err != nil {
return err
}
err = addrKR.VerifyDetachedEncrypted(plainMessage, encSignatureArm, nodeKR, crypto.GetUnixTime())
if err != nil {
return err
}
_, err = buffer.ReadFrom(plainMessage.NewReader())
if err != nil {
return err
}
h := sha256.New()
h.Write(data)
hash := h.Sum(nil)
base64Hash := base64.StdEncoding.EncodeToString(hash)
if err != nil {
return err
}
if base64Hash != originalHash {
return ErrDownloadedBlockHashVerificationFailed
}
return nil
}

101
delete.go Normal file
View File

@@ -0,0 +1,101 @@
package proton_api_bridge
import (
"context"
"github.com/henrybear327/go-proton-api"
)
func (protonDrive *ProtonDrive) moveToTrash(ctx context.Context, parentLinkID string, linkIDs ...string) error {
err := protonDrive.c.TrashChildren(ctx, protonDrive.MainShare.ShareID, parentLinkID, linkIDs...)
if err != nil {
return err
}
return nil
}
func (protonDrive *ProtonDrive) MoveFileToTrashByID(ctx context.Context, linkID string) error {
fileLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, linkID)
if err != nil {
return err
}
if fileLink.Type != proton.LinkTypeFile {
return ErrLinkTypeMustToBeFolderType
}
return protonDrive.moveToTrash(ctx, fileLink.ParentLinkID, linkID)
}
func (protonDrive *ProtonDrive) MoveFolderToTrashByID(ctx context.Context, linkID string, onlyOnEmpty bool) error {
folderLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, linkID)
if err != nil {
return err
}
if folderLink.Type != proton.LinkTypeFolder {
return ErrLinkTypeMustToBeFolderType
}
childrenLinks, err := protonDrive.c.ListChildren(ctx, protonDrive.MainShare.ShareID, linkID, false)
if err != nil {
return err
}
if onlyOnEmpty {
if len(childrenLinks) > 0 {
return ErrFolderIsNotEmpty
}
}
return protonDrive.moveToTrash(ctx, folderLink.ParentLinkID, linkID)
}
// WARNING!!!!
// Everything in the root folder will be moved to trash
// Most likely only used for debugging when the key is messed up
func (protonDrive *ProtonDrive) EmptyRootFolder(ctx context.Context) error {
links, err := protonDrive.c.ListChildren(ctx, protonDrive.MainShare.ShareID, protonDrive.MainShare.LinkID, true)
if err != nil {
return err
}
{
linkIDs := make([]string, 0)
for i := range links {
if links[i].State == proton.LinkStateActive /* use TrashChildren */ {
linkIDs = append(linkIDs, links[i].LinkID)
}
}
err := protonDrive.c.TrashChildren(ctx, protonDrive.MainShare.ShareID, protonDrive.MainShare.LinkID, linkIDs...)
if err != nil {
return err
}
}
{
linkIDs := make([]string, 0)
for i := range links {
if links[i].State != proton.LinkStateActive {
linkIDs = append(linkIDs, links[i].LinkID)
}
}
err := protonDrive.c.DeleteChildren(ctx, protonDrive.MainShare.ShareID, protonDrive.MainShare.LinkID, linkIDs...)
if err != nil {
return err
}
}
return nil
}
// Empty the trash
func (protonDrive *ProtonDrive) EmptyTrash(ctx context.Context) error {
err := protonDrive.c.EmptyTrash(ctx, protonDrive.MainShare.ShareID)
if err != nil {
return err
}
return nil
}

162
drive.go Normal file
View File

@@ -0,0 +1,162 @@
package proton_api_bridge
import (
"context"
"log"
"github.com/henrybear327/Proton-API-Bridge/common"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/henrybear327/go-proton-api"
)
type ProtonDrive struct {
MainShare *proton.Share
RootLink *proton.Link
MainShareKR *crypto.KeyRing
AddrKR *crypto.KeyRing
Config *common.Config
c *proton.Client
m *proton.Manager
userKR *crypto.KeyRing
addrKRs map[string]*crypto.KeyRing
addrData []proton.Address
signatureAddress string
}
func NewDefaultConfig() *common.Config {
return common.NewConfigWithDefaultValues()
}
func NewProtonDrive(ctx context.Context, config *common.Config) (*ProtonDrive, error) {
/* Log in and logout */
m, c, userKR, addrKRs, addrData, err := common.Login(ctx, config)
if err != nil {
return nil, err
}
/*
Current understanding (at the time of the commit)
The volume is the mount point.
A link is like a folder in POSIX.
A share is associated with a link to represent the access control,
and serves as an entry point to a location in the file structure (Volume).
It points to a link, of file or folder type, anywhere in the tree and holds a key called the ShareKey.
To access a link, of file or folder type, a user must be a member of a share.
A volume has a default share for access control and is owned by the creator of the volume.
A volume has a default link as it's root folder.
MIMETYPE holds type, e.g. folder, image/png, etc.
*/
volumes, err := listAllVolumes(ctx, c)
if err != nil {
return nil, err
}
// log.Printf("all volumes %#v", volumes)
mainShareID := ""
for i := range volumes {
// iOS drive: first active volume
if volumes[i].State == proton.VolumeStateActive {
mainShareID = volumes[i].Share.ShareID
}
}
// log.Println("total volumes", len(volumes), "mainShareID", mainShareID)
/* Get root folder from the main share of the volume */
mainShare, err := getShareByID(ctx, c, mainShareID)
if err != nil {
return nil, err
}
// check for main share integrity
{
mainShareCheck := false
shares, err := getAllShares(ctx, c)
if err != nil {
return nil, err
}
for i := range shares {
if shares[i].ShareID == mainShare.ShareID &&
shares[i].LinkID == mainShare.LinkID &&
shares[i].Flags == proton.PrimaryShare &&
shares[i].Type == proton.ShareTypeMain {
mainShareCheck = true
}
}
if !mainShareCheck {
log.Printf("mainShare %#v", mainShare)
log.Printf("shares %#v", shares)
return nil, ErrMainSharePreconditionsFailed
}
}
// Note: rootLink's parentLinkID == ""
/*
Link holds the tree structure, for the clients, they represent the files and folders of a given volume.
They have a ParentLinkID that points to parent folders.
Links also hold the file name (encrypted) and a hash of the name for name collisions.
Link data is encrypted with its owning Share keyring.
*/
rootLink, err := c.GetLink(ctx, mainShare.ShareID, mainShare.LinkID)
if err != nil {
return nil, err
}
if err != nil {
return nil, err
}
// log.Printf("rootLink %#v", rootLink)
// log.Printf("addrKRs %#v", addrKRs)=
addrKR := addrKRs[mainShare.AddressID]
// log.Println("addrKR CountDecryptionEntities", addrKR.CountDecryptionEntities())
mainShareKR, err := mainShare.GetKeyRing(addrKR)
if err != nil {
return nil, err
}
// log.Println("mainShareKR CountDecryptionEntities", mainShareKR.CountDecryptionEntities())
return &ProtonDrive{
MainShare: mainShare,
RootLink: &rootLink,
MainShareKR: mainShareKR,
AddrKR: addrKR,
Config: config,
c: c,
m: m,
userKR: userKR,
addrKRs: addrKRs,
addrData: addrData,
signatureAddress: mainShare.Creator,
}, nil
}
func (protonDrive *ProtonDrive) Logout(ctx context.Context) error {
return common.Logout(ctx, protonDrive.Config, protonDrive.m, protonDrive.c, protonDrive.userKR, protonDrive.addrKRs)
}
func (protonDrive *ProtonDrive) About(ctx context.Context) (*proton.User, error) {
user, err := protonDrive.c.GetUser(ctx)
if err != nil {
return nil, err
}
return &user, nil
}
func (protonDrive *ProtonDrive) GetLink(ctx context.Context, linkID string) (*proton.Link, error) {
link, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, linkID)
return &link, err
}

288
drive_test.go Normal file
View File

@@ -0,0 +1,288 @@
package proton_api_bridge
import (
"context"
"log"
"strings"
"testing"
"github.com/henrybear327/Proton-API-Bridge/common"
"github.com/henrybear327/Proton-API-Bridge/utility"
)
func setup(t *testing.T) (context.Context, context.CancelFunc, *ProtonDrive) {
utility.SetupLog()
config := common.NewConfigForIntegrationTests()
{
// pre-condition check
if !config.DestructiveIntegrationTest {
t.Fatalf("CAUTION: the integration test requires a clean proton drive")
}
if !config.EmptyTrashAfterIntegrationTest {
t.Fatalf("CAUTION: the integration test requires cleaning up the drive after running the tests")
}
}
ctx, cancel := context.WithCancel(context.Background())
protonDrive, err := NewProtonDrive(ctx, config)
if err != nil {
t.Fatal(err)
}
err = protonDrive.EmptyRootFolder(ctx)
if err != nil {
t.Fatal(err)
}
err = protonDrive.EmptyTrash(ctx)
if err != nil {
t.Fatal(err)
}
return ctx, cancel, protonDrive
}
func tearDown(t *testing.T, ctx context.Context, protonDrive *ProtonDrive) {
if protonDrive.Config.EmptyTrashAfterIntegrationTest {
err := protonDrive.EmptyTrash(ctx)
if err != nil {
t.Fatal(err)
}
}
}
/* Integration Tests */
func TestCreateAndDeleteFolder(t *testing.T) {
ctx, cancel, protonDrive := setup(t)
t.Cleanup(func() {
defer cancel()
defer tearDown(t, ctx, protonDrive)
})
log.Println("Create a folder tmp at root")
createFolder(t, ctx, protonDrive, "", "tmp")
checkFileListing(t, ctx, protonDrive, []string{"/tmp"})
log.Println("Delet folder tmp")
deleteBySearchingFromRoot(t, ctx, protonDrive, "tmp", true)
checkFileListing(t, ctx, protonDrive, []string{})
}
func TestUploadAndDownloadAndDeleteAFile(t *testing.T) {
ctx, cancel, protonDrive := setup(t)
t.Cleanup(func() {
defer cancel()
defer tearDown(t, ctx, protonDrive)
})
log.Println("Upload integrationTestImage.png")
uploadFileByFilepath(t, ctx, protonDrive, "", "integrationTestImage.png", "testcase/integrationTestImage.png")
checkRevisions(protonDrive, ctx, t, "integrationTestImage.png", 1)
checkFileListing(t, ctx, protonDrive, []string{"/integrationTestImage.png"})
downloadFile(t, ctx, protonDrive, "", "integrationTestImage.png", "testcase/integrationTestImage.png", "")
log.Println("Upload a new revision to replace integrationTestImage.png")
uploadFileByFilepath(t, ctx, protonDrive, "", "integrationTestImage.png", "testcase/integrationTestImage2.png") /* Add a revision */
checkRevisions(protonDrive, ctx, t, "integrationTestImage.png", 2)
downloadFile(t, ctx, protonDrive, "", "integrationTestImage.png", "testcase/integrationTestImage2.png", "")
checkFileListing(t, ctx, protonDrive, []string{"/integrationTestImage.png"})
log.Println("Delete file integrationTestImage.png")
deleteBySearchingFromRoot(t, ctx, protonDrive, "integrationTestImage.png", false)
checkFileListing(t, ctx, protonDrive, []string{})
}
func TestUploadAndDeleteAnEmptyFileAtRoot(t *testing.T) {
ctx, cancel, protonDrive := setup(t)
t.Cleanup(func() {
defer cancel()
defer tearDown(t, ctx, protonDrive)
})
log.Println("Upload empty.txt")
uploadFileByFilepath(t, ctx, protonDrive, "", "empty.txt", "testcase/empty.txt")
checkRevisions(protonDrive, ctx, t, "empty.txt", 1)
checkFileListing(t, ctx, protonDrive, []string{"/empty.txt"})
downloadFile(t, ctx, protonDrive, "", "empty.txt", "testcase/empty.txt", "")
log.Println("Upload a new revision to replace empty.txt")
uploadFileByFilepath(t, ctx, protonDrive, "", "empty.txt", "testcase/empty.txt") /* Add a revision */
checkRevisions(protonDrive, ctx, t, "empty.txt", 2)
downloadFile(t, ctx, protonDrive, "", "empty.txt", "testcase/empty.txt", "")
checkFileListing(t, ctx, protonDrive, []string{"/empty.txt"})
log.Println("Delete file empty.txt")
deleteBySearchingFromRoot(t, ctx, protonDrive, "empty.txt", false)
checkFileListing(t, ctx, protonDrive, []string{})
}
func TestUploadAndDownloadAndDeleteAFileAtAFolderOneLevelFromRoot(t *testing.T) {
ctx, cancel, protonDrive := setup(t)
t.Cleanup(func() {
defer cancel()
defer tearDown(t, ctx, protonDrive)
})
log.Println("Create folder level1")
createFolder(t, ctx, protonDrive, "", "level1")
checkFileListing(t, ctx, protonDrive, []string{"/level1"})
log.Println("Upload integrationTestImage.png to level1")
uploadFileByFilepath(t, ctx, protonDrive, "level1", "integrationTestImage.png", "testcase/integrationTestImage.png")
checkRevisions(protonDrive, ctx, t, "integrationTestImage.png", 1)
checkFileListing(t, ctx, protonDrive, []string{"/level1", "/level1/integrationTestImage.png"})
downloadFile(t, ctx, protonDrive, "level1", "integrationTestImage.png", "testcase/integrationTestImage.png", "")
log.Println("Upload a new revision to replace integrationTestImage.png in level1")
uploadFileByFilepath(t, ctx, protonDrive, "level1", "integrationTestImage.png", "testcase/integrationTestImage2.png") /* Add a revision */
checkRevisions(protonDrive, ctx, t, "integrationTestImage.png", 2)
downloadFile(t, ctx, protonDrive, "level1", "integrationTestImage.png", "testcase/integrationTestImage2.png", "")
log.Println("Delete folder level1")
deleteBySearchingFromRoot(t, ctx, protonDrive, "level1", true)
checkFileListing(t, ctx, protonDrive, []string{})
}
func TestCreateAndMoveAndDeleteFolder(t *testing.T) {
ctx, cancel, protonDrive := setup(t)
t.Cleanup(func() {
defer cancel()
defer tearDown(t, ctx, protonDrive)
})
log.Println("Create a folder src at root")
createFolder(t, ctx, protonDrive, "", "src")
checkFileListing(t, ctx, protonDrive, []string{"/src"})
log.Println("Create a folder dst at root")
createFolder(t, ctx, protonDrive, "", "dst")
checkFileListing(t, ctx, protonDrive, []string{"/src", "/dst"})
log.Println("Move folder src to under folder dst")
moveFolder(t, ctx, protonDrive, "src", "dst")
checkFileListing(t, ctx, protonDrive, []string{"/dst", "/dst/src"})
log.Println("Delete folder dst")
deleteBySearchingFromRoot(t, ctx, protonDrive, "dst", true)
checkFileListing(t, ctx, protonDrive, []string{})
}
func TestCreateAndMoveAndDeleteFolderWithAFile(t *testing.T) {
ctx, cancel, protonDrive := setup(t)
t.Cleanup(func() {
defer cancel()
defer tearDown(t, ctx, protonDrive)
})
log.Println("Create a folder src at root")
createFolder(t, ctx, protonDrive, "", "src")
checkFileListing(t, ctx, protonDrive, []string{"/src"})
log.Println("Upload integrationTestImage.png to src")
uploadFileByFilepath(t, ctx, protonDrive, "src", "integrationTestImage.png", "testcase/integrationTestImage.png")
checkRevisions(protonDrive, ctx, t, "integrationTestImage.png", 1)
checkFileListing(t, ctx, protonDrive, []string{"/src", "/src/integrationTestImage.png"})
downloadFile(t, ctx, protonDrive, "src", "integrationTestImage.png", "testcase/integrationTestImage.png", "")
log.Println("Create a folder dst at root")
createFolder(t, ctx, protonDrive, "", "dst")
checkFileListing(t, ctx, protonDrive, []string{"/src", "/src/integrationTestImage.png", "/dst"})
log.Println("Upload a new revision to replace integrationTestImage.png in src")
uploadFileByFilepath(t, ctx, protonDrive, "src", "integrationTestImage.png", "testcase/integrationTestImage2.png") /* Add a revision */
checkRevisions(protonDrive, ctx, t, "integrationTestImage.png", 2)
downloadFile(t, ctx, protonDrive, "src", "integrationTestImage.png", "testcase/integrationTestImage2.png", "")
checkFileListing(t, ctx, protonDrive, []string{"/src", "/src/integrationTestImage.png", "/dst"})
log.Println("Move folder src to under folder dst")
moveFolder(t, ctx, protonDrive, "src", "dst")
checkFileListing(t, ctx, protonDrive, []string{"/dst", "/dst/src", "/dst/src/integrationTestImage.png"})
log.Println("Delete folder dst")
deleteBySearchingFromRoot(t, ctx, protonDrive, "dst", true)
checkFileListing(t, ctx, protonDrive, []string{})
}
func TestCreateAndMoveAndDeleteAFileOneLevelFromRoot(t *testing.T) {
ctx, cancel, protonDrive := setup(t)
t.Cleanup(func() {
defer cancel()
defer tearDown(t, ctx, protonDrive)
})
log.Println("Create a folder src at root")
createFolder(t, ctx, protonDrive, "", "src")
checkFileListing(t, ctx, protonDrive, []string{"/src"})
log.Println("Upload integrationTestImage.png to src")
uploadFileByFilepath(t, ctx, protonDrive, "src", "integrationTestImage.png", "testcase/integrationTestImage.png")
checkRevisions(protonDrive, ctx, t, "integrationTestImage.png", 1)
checkFileListing(t, ctx, protonDrive, []string{"/src", "/src/integrationTestImage.png"})
downloadFile(t, ctx, protonDrive, "src", "integrationTestImage.png", "testcase/integrationTestImage.png", "")
log.Println("Create a folder dst at root")
createFolder(t, ctx, protonDrive, "", "dst")
checkFileListing(t, ctx, protonDrive, []string{"/src", "/src/integrationTestImage.png", "/dst"})
log.Println("Upload a new revision to replace integrationTestImage.png in src")
uploadFileByFilepath(t, ctx, protonDrive, "src", "integrationTestImage.png", "testcase/integrationTestImage2.png") /* Add a revision */
checkRevisions(protonDrive, ctx, t, "integrationTestImage.png", 2)
downloadFile(t, ctx, protonDrive, "src", "integrationTestImage.png", "testcase/integrationTestImage2.png", "")
checkFileListing(t, ctx, protonDrive, []string{"/src", "/src/integrationTestImage.png", "/dst"})
log.Println("Move file integrationTestImage.png to under folder dst")
moveFile(t, ctx, protonDrive, "integrationTestImage.png", "dst")
checkFileListing(t, ctx, protonDrive, []string{"/src", "/dst", "/dst/integrationTestImage.png"})
log.Println("Delete folder dst")
deleteBySearchingFromRoot(t, ctx, protonDrive, "dst", true)
checkFileListing(t, ctx, protonDrive, []string{"/src"})
log.Println("Delete folder src")
deleteBySearchingFromRoot(t, ctx, protonDrive, "src", true)
checkFileListing(t, ctx, protonDrive, []string{})
}
func TestUploadLargeNumberOfBlocks(t *testing.T) {
ctx, cancel, protonDrive := setup(t)
t.Cleanup(func() {
defer cancel()
defer tearDown(t, ctx, protonDrive)
})
// in order to simulate uploading large files
// we use 1KB for the UPLOAD_BLOCK_SIZE
// so a 1000KB file will generate 1000 blocks to test the uploading mechanism
// and also testing the downloading mechanism
ORIGINAL_UPLOAD_BLOCK_SIZE := UPLOAD_BLOCK_SIZE
defer func() {
UPLOAD_BLOCK_SIZE = ORIGINAL_UPLOAD_BLOCK_SIZE
}()
blocks := 100
UPLOAD_BLOCK_SIZE = 10
filename := "fileContent.txt"
file1Content := RandomString(UPLOAD_BLOCK_SIZE * blocks)
file1ContentReader := strings.NewReader(file1Content)
file2Content := RandomString(UPLOAD_BLOCK_SIZE * blocks)
file2ContentReader := strings.NewReader(file2Content)
log.Println("Upload fileContent.txt")
uploadFileByReader(t, ctx, protonDrive, "", filename, file1ContentReader)
checkRevisions(protonDrive, ctx, t, filename, 1)
checkFileListing(t, ctx, protonDrive, []string{"/" + filename})
downloadFile(t, ctx, protonDrive, "", filename, "", file1Content)
log.Println("Upload a new revision to replace fileContent.txt")
uploadFileByReader(t, ctx, protonDrive, "", filename, file2ContentReader)
checkRevisions(protonDrive, ctx, t, filename, 2)
checkFileListing(t, ctx, protonDrive, []string{"/" + filename})
downloadFile(t, ctx, protonDrive, "", filename, "", file2Content)
log.Println("Delete file fileContent.txt")
deleteBySearchingFromRoot(t, ctx, protonDrive, filename, false)
checkFileListing(t, ctx, protonDrive, []string{})
}

308
drive_test_helper.go Normal file
View File

@@ -0,0 +1,308 @@
package proton_api_bridge
import (
"bufio"
"bytes"
"context"
"io"
"os"
"testing"
"time"
"github.com/henrybear327/go-proton-api"
mathrand "math/rand"
)
// Taken from: https://github.com/rclone/rclone/blob/e43b5ce5e59b5717a9819ff81805dd431f710c10/lib/random/random.go
//
// StringFn create a random string for test purposes using the random
// number generator function passed in.
//
// Do not use these for passwords.
func StringFn(n int, randIntn func(n int) int) string {
const (
vowel = "aeiou"
consonant = "bcdfghjklmnpqrstvwxyz"
digit = "0123456789"
)
pattern := []string{consonant, vowel, consonant, vowel, consonant, vowel, consonant, digit}
out := make([]byte, n)
p := 0
for i := range out {
source := pattern[p]
p = (p + 1) % len(pattern)
out[i] = source[randIntn(len(source))]
}
return string(out)
}
// String create a random string for test purposes.
//
// Do not use these for passwords.
func RandomString(n int) string {
return StringFn(n, mathrand.Intn)
}
/* Helper functions */
func createFolder(t *testing.T, ctx context.Context, protonDrive *ProtonDrive, parent, name string) {
parentLink := protonDrive.RootLink
if parent != "" {
targetFolderLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, parent, true)
if err != nil {
t.Fatal(err)
}
if targetFolderLink == nil {
t.Fatalf("Folder %v not found", parent)
}
parentLink = targetFolderLink
}
if parentLink.Type != proton.LinkTypeFolder {
t.Fatalf("parentLink is not of folder type")
}
_, err := protonDrive.CreateNewFolderByID(ctx, parentLink.LinkID, name)
if err != nil {
t.Fatal(err)
}
}
func uploadFileByReader(t *testing.T, ctx context.Context, protonDrive *ProtonDrive, parent, name string, in io.Reader) {
parentLink := protonDrive.RootLink
if parent != "" {
targetFolderLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, parent, true)
if err != nil {
t.Fatal(err)
}
if targetFolderLink == nil {
t.Fatalf("Folder %v not found", parent)
}
parentLink = targetFolderLink
}
if parentLink.Type != proton.LinkTypeFolder {
t.Fatalf("parentLink is not of folder type")
}
_, _, err := protonDrive.UploadFileByReader(ctx, parentLink.LinkID, name, time.Now(), in)
if err != nil {
t.Fatal(err)
}
}
func uploadFileByFilepath(t *testing.T, ctx context.Context, protonDrive *ProtonDrive, parent, name string, filepath string) {
parentLink := protonDrive.RootLink
if parent != "" {
targetFolderLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, parent, true)
if err != nil {
t.Fatal(err)
}
if targetFolderLink == nil {
t.Fatalf("Folder %v not found", parent)
}
parentLink = targetFolderLink
}
if parentLink.Type != proton.LinkTypeFolder {
t.Fatalf("parentLink is not of folder type")
}
f, err := os.Open(filepath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
info, err := os.Stat(filepath)
if err != nil {
t.Fatal(err)
}
in := bufio.NewReader(f)
_, _, err = protonDrive.UploadFileByReader(ctx, parentLink.LinkID, name, info.ModTime(), in)
if err != nil {
t.Fatal(err)
}
}
func downloadFile(t *testing.T, ctx context.Context, protonDrive *ProtonDrive, parent, name string, filepath string, data string) {
parentLink := protonDrive.RootLink
if parent != "" {
targetFolderLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, parent, true)
if err != nil {
t.Fatal(err)
}
if targetFolderLink == nil {
t.Fatalf("Folder %v not found", parent)
}
parentLink = targetFolderLink
}
if parentLink.Type != proton.LinkTypeFolder {
t.Fatalf("parentLink is not of folder type")
}
targetFileLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, name, false)
if err != nil {
t.Fatal(err)
}
if targetFileLink == nil {
t.Fatalf("File %v not found", name)
} else {
downloadedData, fileSystemAttr, err := protonDrive.DownloadFileByID(ctx, targetFileLink.LinkID)
if err != nil {
t.Fatal(err)
}
/* Check file metadata */
if fileSystemAttr == nil {
t.Fatalf("FileSystemAttr should not be nil")
} else {
if len(downloadedData) != int(fileSystemAttr.Size) {
t.Fatalf("Downloaded file size != uploaded file size: %#v vs %#v", len(downloadedData), int(fileSystemAttr.Size))
}
}
if filepath != "" {
originalData, err := os.ReadFile(filepath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(downloadedData, originalData) {
t.Fatalf("Downloaded content is different from the original content")
}
} else if data != "" {
if !bytes.Equal(downloadedData, []byte(data)) {
t.Fatalf("Downloaded content is different from the original content")
}
} else {
t.Fatalf("Nothing to verify against")
}
}
}
func checkRevisions(protonDrive *ProtonDrive, ctx context.Context, t *testing.T, name string, totalRevisions int) {
targetFileLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, name, false)
if err != nil {
t.Fatal(err)
}
if targetFileLink == nil {
t.Fatalf("File %v not found", name)
} else {
revisions, err := protonDrive.c.ListRevisions(ctx, protonDrive.MainShare.ShareID, targetFileLink.LinkID)
if err != nil {
t.Fatal(err)
}
if len(revisions) != totalRevisions {
t.Fatalf("Missing revision")
}
}
}
// During the integration test, the name much be unique since the link is returned by recursively search for the name from root
func deleteBySearchingFromRoot(t *testing.T, ctx context.Context, protonDrive *ProtonDrive, name string, isFolder bool) {
targetLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, name, isFolder)
if err != nil {
t.Fatal(err)
}
if targetLink == nil {
t.Fatalf("Target %v to be deleted not found", name)
} else {
if isFolder {
err = protonDrive.MoveFolderToTrashByID(ctx, targetLink.LinkID, false)
if err != nil {
t.Fatal(err)
}
} else {
err = protonDrive.MoveFileToTrashByID(ctx, targetLink.LinkID)
if err != nil {
t.Fatal(err)
}
}
}
}
func checkFileListing(t *testing.T, ctx context.Context, protonDrive *ProtonDrive, expectedPaths []string) {
{
paths := make([]string, 0)
err := protonDrive.ListDirectoriesRecursively(ctx, protonDrive.MainShareKR, protonDrive.RootLink, false, -1, 0, true, "", &paths)
if err != nil {
t.Fatal(err)
}
if len(paths) != len(expectedPaths) {
t.Fatalf("Total path returned is differs from expected\nReturned %#v\nExpected: %#v\n", paths, expectedPaths)
}
for i := range paths {
if paths[i] != expectedPaths[i] {
t.Fatalf("The path returned is differs from the path expected\nReturned %#v\nExpected: %#v\n", paths, expectedPaths)
}
}
}
{
paths := make([]string, 0)
err := protonDrive.ListDirectoriesRecursively(ctx, protonDrive.MainShareKR, protonDrive.RootLink, false, -1, 0, false, "", &paths)
if err != nil {
t.Fatal(err)
}
// transform
newExpectedPath := make([]string, 0)
newExpectedPath = append(newExpectedPath, "/root")
for i := range expectedPaths {
newExpectedPath = append(newExpectedPath, "/root"+expectedPaths[i])
}
if len(paths) != len(newExpectedPath) {
t.Fatalf("Total path returned is differs from expected\nReturned %#v\nExpected: %#v\n", paths, newExpectedPath)
}
for i := range paths {
if paths[i] != newExpectedPath[i] {
t.Fatalf("The path returned is differs from the path expected\nReturned %#v\nExpected: %#v\n", paths, newExpectedPath)
}
}
}
}
func moveFolder(t *testing.T, ctx context.Context, protonDrive *ProtonDrive, srcFolderName, dstParentFolderName string) {
targetSrcFolderLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, srcFolderName, true)
if err != nil {
t.Fatal(err)
}
targetDestFolderLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, dstParentFolderName, true)
if err != nil {
t.Fatal(err)
}
if targetSrcFolderLink == nil || targetDestFolderLink == nil {
t.Fatalf("Folder %s or %s found", srcFolderName, dstParentFolderName)
} else {
err := protonDrive.MoveFolder(ctx, targetSrcFolderLink, targetDestFolderLink, srcFolderName)
if err != nil {
t.Fatal(err)
}
}
}
func moveFile(t *testing.T, ctx context.Context, protonDrive *ProtonDrive, srcFileName, dstParentFolderName string) {
targetSrcFileLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, srcFileName, false)
if err != nil {
t.Fatal(err)
}
targetDestFolderLink, err := protonDrive.SearchByNameRecursivelyFromRoot(ctx, dstParentFolderName, true)
if err != nil {
t.Fatal(err)
}
if targetSrcFileLink == nil || targetDestFolderLink == nil {
t.Fatalf("File %s or folder %s found", srcFileName, dstParentFolderName)
} else {
err := protonDrive.MoveFile(ctx, targetSrcFileLink, targetDestFolderLink, srcFileName)
if err != nil {
t.Fatal(err)
}
}
}

16
error.go Normal file
View File

@@ -0,0 +1,16 @@
package proton_api_bridge
import "errors"
var (
ErrMainSharePreconditionsFailed = errors.New("the main share assumption has failed")
ErrDataFolderNameIsEmpty = errors.New("please supply a DataFolderName to enabling file downloading")
ErrLinkTypeMustToBeFolderType = errors.New("the link type must be of folder type")
ErrLinkTypeMustToBeFileType = errors.New("the link type must be of file type")
ErrFolderIsNotEmpty = errors.New("folder can't be deleted becuase it is not empty")
ErrInternalErrorOnFileUpload = errors.New("either link or createFileResp must be not nil")
ErrMissingInputUploadAndCollectBlockData = errors.New("missing either session key or key ring")
ErrLinkMustNotBeNil = errors.New("missing input proton link")
ErrLinkMustBeActive = errors.New("can not operate on link state other than active")
ErrDownloadedBlockHashVerificationFailed = errors.New("the hash of the downloaded block doesn't match the original hash")
)

457
file.go Normal file
View File

@@ -0,0 +1,457 @@
package proton_api_bridge
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"io"
"os"
"strings"
"time"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/gabriel-vasile/mimetype"
"github.com/henrybear327/go-proton-api"
"github.com/relvacode/iso8601"
)
type FileSystemAttrs struct {
ModificationTime time.Time
Size int64
}
func (protonDrive *ProtonDrive) DownloadFileByID(ctx context.Context, linkID string) ([]byte, *FileSystemAttrs, error) {
link, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, linkID)
if err != nil {
return nil, nil, err
}
return protonDrive.DownloadFile(ctx, &link)
}
func (protonDrive *ProtonDrive) GetActiveRevision(ctx context.Context, link *proton.Link) (*proton.Revision, error) {
revisions, err := protonDrive.c.ListRevisions(ctx, protonDrive.MainShare.ShareID, link.LinkID)
if err != nil {
return nil, err
}
// log.Printf("revisions %#v", revisions)
// Revisions are only for files, they represent “versions” of files.
// Each file can have 1 active revision and n obsolete revisions.
activeRevision := -1
for i := range revisions {
if revisions[i].State == proton.RevisionStateActive {
activeRevision = i
}
}
revision, err := protonDrive.c.GetRevisionAllBlocks(ctx, protonDrive.MainShare.ShareID, link.LinkID, revisions[activeRevision].ID)
if err != nil {
return nil, err
}
// log.Println("Total blocks", len(revision.Blocks))
return &revision, nil
}
func (protonDrive *ProtonDrive) GetActiveRevisionWithAttrs(ctx context.Context, link *proton.Link) (*proton.Revision, *FileSystemAttrs, error) {
if link == nil {
return nil, nil, ErrLinkMustNotBeNil
}
revision, err := protonDrive.GetActiveRevision(ctx, link)
if err != nil {
return nil, nil, err
}
nodeKR, err := protonDrive.getNodeKR(ctx, link)
if err != nil {
return nil, nil, err
}
revisionXAttrCommon, err := revision.GetDecXAttrString(protonDrive.AddrKR, nodeKR)
if err != nil {
return nil, nil, err
}
modificationTime, err := iso8601.ParseString(revisionXAttrCommon.ModificationTime)
if err != nil {
return nil, nil, err
}
return revision, &FileSystemAttrs{
ModificationTime: modificationTime,
Size: revisionXAttrCommon.Size,
}, nil
}
func (protonDrive *ProtonDrive) DownloadFile(ctx context.Context, link *proton.Link) ([]byte, *FileSystemAttrs, error) {
if link.Type != proton.LinkTypeFile {
return nil, nil, ErrLinkTypeMustToBeFileType
}
parentNodeKR, err := protonDrive.getNodeKRByID(ctx, link.ParentLinkID)
if err != nil {
return nil, nil, err
}
nodeKR, err := link.GetKeyRing(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return nil, nil, err
}
sessionKey, err := link.GetSessionKey(protonDrive.AddrKR, nodeKR)
if err != nil {
return nil, nil, err
}
revision, fileSystemAttrs, err := protonDrive.GetActiveRevisionWithAttrs(ctx, link)
if err != nil {
return nil, nil, err
}
buffer := bytes.NewBuffer(nil)
for i := range revision.Blocks {
// TODO: parallel download
blockReader, err := protonDrive.c.GetBlock(ctx, revision.Blocks[i].BareURL, revision.Blocks[i].Token)
if err != nil {
return nil, nil, err
}
defer blockReader.Close()
err = decryptBlockIntoBuffer(sessionKey, protonDrive.AddrKR, nodeKR, revision.Blocks[i].Hash, revision.Blocks[i].EncSignature, buffer, blockReader)
if err != nil {
return nil, nil, err
}
}
if fileSystemAttrs != nil {
return buffer.Bytes(), fileSystemAttrs, nil
}
return buffer.Bytes(), nil, nil
}
func (protonDrive *ProtonDrive) UploadFileByReader(ctx context.Context, parentLinkID string, filename string, modTime time.Time, file io.Reader) (*proton.Link, int64, error) {
parentLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, parentLinkID)
if err != nil {
return nil, 0, err
}
return protonDrive.uploadFile(ctx, &parentLink, filename, modTime, file)
}
func (protonDrive *ProtonDrive) UploadFileByPath(ctx context.Context, parentLink *proton.Link, filename string, filePath string) (*proton.Link, int64, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, 0, err
}
defer f.Close()
info, err := os.Stat(filePath)
if err != nil {
return nil, 0, err
}
in := bufio.NewReader(f)
return protonDrive.uploadFile(ctx, parentLink, filename, info.ModTime(), in)
}
func (protonDrive *ProtonDrive) createFileUploadDraft(ctx context.Context, parentLink *proton.Link, filename string, modTime time.Time, mimeType string) (*proton.Link, *proton.CreateFileRes, *crypto.SessionKey, *crypto.KeyRing, error) {
parentNodeKR, err := protonDrive.getNodeKR(ctx, parentLink)
if err != nil {
return nil, nil, nil, nil, err
}
newNodeKey, newNodePassphraseEnc, newNodePassphraseSignature, err := generateNodeKeys(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return nil, nil, nil, nil, err
}
createFileReq := proton.CreateFileReq{
ParentLinkID: parentLink.LinkID,
// Name string // Encrypted File Name
// Hash string // Encrypted File Name hash
MIMEType: mimeType, // MIME Type
// ContentKeyPacket string // The block's key packet, encrypted with the node key.
// ContentKeyPacketSignature string // Unencrypted signature of the content session key, signed with the NodeKey
NodeKey: newNodeKey, // The private NodeKey, used to decrypt any file/folder content.
NodePassphrase: newNodePassphraseEnc, // The passphrase used to unlock the NodeKey, encrypted by the owning Link/Share keyring.
NodePassphraseSignature: newNodePassphraseSignature, // The signature of the NodePassphrase
SignatureAddress: protonDrive.signatureAddress, // Signature email address used to sign passphrase and name
}
/* Name is encrypted using the parent's keyring, and signed with address key */
err = createFileReq.SetName(filename, protonDrive.AddrKR, parentNodeKR)
if err != nil {
return nil, nil, nil, nil, err
}
parentHashKey, err := parentLink.GetHashKey(parentNodeKR)
if err != nil {
return nil, nil, nil, nil, err
}
newNodeKR, err := getKeyRing(parentNodeKR, protonDrive.AddrKR, newNodeKey, newNodePassphraseEnc, newNodePassphraseSignature)
if err != nil {
return nil, nil, nil, nil, err
}
err = createFileReq.SetHash(filename, parentHashKey)
if err != nil {
return nil, nil, nil, nil, err
}
newSessionKey, err := createFileReq.SetContentKeyPacketAndSignature(newNodeKR, protonDrive.AddrKR)
if err != nil {
return nil, nil, nil, nil, err
}
createFileResp, err := protonDrive.c.CreateFile(ctx, protonDrive.MainShare.ShareID, createFileReq)
if err != nil {
// FIXME: check for duplicated filename by using checkAvailableHashes
// FIXME: better error handling
// 422: A file or folder with that name already exists (Code=2500, Status=422)
if strings.Contains(err.Error(), "(Code=2500, Status=422)") {
// file name conflict, file already exists
link, err := protonDrive.SearchByNameInFolder(ctx, parentLink, filename, true, false)
if err != nil {
return nil, nil, nil, nil, err
}
return link, nil, nil, nil, nil
}
// other real error caught
return nil, nil, nil, nil, err
}
return nil, &createFileResp, newSessionKey, newNodeKR, nil
}
func (protonDrive *ProtonDrive) uploadAndCollectBlockData(ctx context.Context, newSessionKey *crypto.SessionKey, newNodeKR *crypto.KeyRing, fileContent []byte, linkID, revisionID string) ([]byte, []proton.BlockToken, error) {
if newSessionKey == nil || newNodeKR == nil {
return nil, nil, ErrMissingInputUploadAndCollectBlockData
}
// FIXME: handle partial upload (failed midway)
// FIXME: get block size from the server config instead of hardcoding it
blockSize := UPLOAD_BLOCK_SIZE
type PendingUploadBlocks struct {
blockUploadInfo proton.BlockUploadInfo
encData []byte
}
blocks := make([]PendingUploadBlocks, 0)
manifestSignatureData := make([]byte, 0)
for i := 0; i*blockSize < len(fileContent); i++ {
// encrypt data
upperBound := (i + 1) * blockSize
if upperBound > len(fileContent) {
upperBound = len(fileContent)
}
data := fileContent[i*blockSize : upperBound]
dataPlainMessage := crypto.NewPlainMessage(data)
encData, err := newSessionKey.Encrypt(dataPlainMessage)
if err != nil {
return nil, nil, err
}
encSignature, err := protonDrive.AddrKR.SignDetachedEncrypted(dataPlainMessage, newNodeKR)
if err != nil {
return nil, nil, err
}
encSignatureStr, err := encSignature.GetArmored()
if err != nil {
return nil, nil, err
}
h := sha256.New()
h.Write(encData)
hash := h.Sum(nil)
base64Hash := base64.StdEncoding.EncodeToString(hash)
if err != nil {
return nil, nil, err
}
manifestSignatureData = append(manifestSignatureData, hash...)
blocks = append(blocks, PendingUploadBlocks{
blockUploadInfo: proton.BlockUploadInfo{
Index: i + 1, // iOS drive: BE starts with 1
Size: int64(len(encData)),
EncSignature: encSignatureStr,
Hash: base64Hash,
},
encData: encData,
})
}
blockList := make([]proton.BlockUploadInfo, 0)
for i := 0; i < len(blocks); i++ {
blockList = append(blockList, blocks[i].blockUploadInfo)
}
blockTokens := make([]proton.BlockToken, 0)
blockUploadReq := proton.BlockUploadReq{
AddressID: protonDrive.MainShare.AddressID,
ShareID: protonDrive.MainShare.ShareID,
LinkID: linkID,
RevisionID: revisionID,
BlockList: blockList,
}
blockUploadResp, err := protonDrive.c.RequestBlockUpload(ctx, blockUploadReq)
if err != nil {
return nil, nil, err
}
for i := range blockUploadResp {
err := protonDrive.c.UploadBlock(ctx, blockUploadResp[i].BareURL, blockUploadResp[i].Token, bytes.NewReader(blocks[i].encData))
if err != nil {
return nil, nil, err
}
blockTokens = append(blockTokens, proton.BlockToken{
Index: i + 1,
Token: blockUploadResp[i].Token,
})
}
return manifestSignatureData, blockTokens, nil
}
func (protonDrive *ProtonDrive) commitNewRevision(ctx context.Context, nodeKR *crypto.KeyRing, modificationTime time.Time, size int64, manifestSignatureData []byte, blockTokens []proton.BlockToken, linkID, revisionID string) error {
// TODO: check iOS Drive CommitableRevision
manifestSignature, err := protonDrive.AddrKR.SignDetached(crypto.NewPlainMessage(manifestSignatureData))
if err != nil {
return err
}
manifestSignatureString, err := manifestSignature.GetArmored()
if err != nil {
return err
}
updateRevisionReq := proton.UpdateRevisionReq{
BlockList: blockTokens,
State: proton.RevisionStateActive,
ManifestSignature: manifestSignatureString,
SignatureAddress: protonDrive.signatureAddress,
}
err = updateRevisionReq.SetEncXAttrString(protonDrive.AddrKR, nodeKR, modificationTime, size)
if err != nil {
return err
}
err = protonDrive.c.UpdateRevision(ctx, protonDrive.MainShare.ShareID, linkID, revisionID, updateRevisionReq)
if err != nil {
return err
}
return nil
}
func (protonDrive *ProtonDrive) uploadFile(ctx context.Context, parentLink *proton.Link, filename string, modTime time.Time, file io.Reader) (*proton.Link, int64, error) {
// FIXME: check iOS: optimize for large files -> enc blocks on the fly
// main issue lies in the mimetype detection, since a full readout is used
// detect MIME type
fileContent, err := io.ReadAll(file)
if err != nil {
return nil, 0, err
}
fileSize := int64(len(fileContent))
mimetype.SetLimit(0)
mType := mimetype.Detect(fileContent)
mimeType := mType.String()
/* step 1: create a draft */
link, createFileResp, newSessionKey, newNodeKR, err := protonDrive.createFileUploadDraft(ctx, parentLink, filename, modTime, mimeType)
if err != nil {
return nil, 0, err
}
linkID := ""
revisionID := ""
if link != nil {
linkID = link.LinkID
// get a new revision
newRevision, err := protonDrive.c.CreateRevision(ctx, protonDrive.MainShare.ShareID, linkID)
if err != nil {
return nil, 0, err
}
revisionID = newRevision.ID
// get newSessionKey and newNodeKR
parentNodeKR, err := protonDrive.getNodeKRByID(ctx, link.ParentLinkID)
if err != nil {
return nil, 0, err
}
newNodeKR, err = link.GetKeyRing(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return nil, 0, err
}
newSessionKey, err = link.GetSessionKey(protonDrive.AddrKR, newNodeKR)
if err != nil {
return nil, 0, err
}
} else if createFileResp != nil {
linkID = createFileResp.ID
revisionID = createFileResp.RevisionID
} else {
// might be the case where the upload failed, since file search will not include file with type draft
return nil, 0, ErrInternalErrorOnFileUpload
}
if fileSize == 0 {
/* step 2: upload blocks and collect block data */
// skipped: no block to upload
/* step 3: mark the file as active by updating the revision */
manifestSignature := make([]byte, 0)
blockTokens := make([]proton.BlockToken, 0)
err = protonDrive.commitNewRevision(ctx, newNodeKR, modTime, fileSize, manifestSignature, blockTokens, linkID, revisionID)
if err != nil {
return nil, 0, err
}
} else {
/* step 2: upload blocks and collect block data */
manifestSignatureData, blockTokens, err := protonDrive.uploadAndCollectBlockData(ctx, newSessionKey, newNodeKR, fileContent, linkID, revisionID)
if err != nil {
return nil, 0, err
}
/* step 3: mark the file as active by updating the revision */
err = protonDrive.commitNewRevision(ctx, newNodeKR, modTime, fileSize, manifestSignatureData, blockTokens, linkID, revisionID)
if err != nil {
return nil, 0, err
}
}
finalLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, linkID)
if err != nil {
return nil, 0, err
}
return &finalLink, fileSize, nil
}
/*
There is a route that proton-go-api doesn't have - checkAvailableHashes.
This is used to quickly find the next available filename when the originally supplied filename is taken in the current folder.
Based on the code below, which is taken from the Proton iOS Drive app, we can infer that:
- when a file is to be uploaded && there is filename conflict after the first upload:
- on web, user will be prompted with a) overwrite b) keep both by appending filename with iteration number c) do nothing
- on the iOS client logic, we can see that when the filename conflict happens (after the upload attampt failed)
- the filename will be hashed by using filename + iteration
- 10 iterations will be done per batch, each iteration's hash will be sent to the server
- the server will return available hashes, and the client will take the lowest iteration as the filename to be used
- will be used to search for the next available filename (using hashes avoids the filename being known to the server)
*/

318
folder.go Normal file
View File

@@ -0,0 +1,318 @@
package proton_api_bridge
import (
"context"
"log"
"os"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/henrybear327/go-proton-api"
)
type ProtonDirectoryData struct {
Link *proton.Link
Name string
IsFolder bool
}
func (protonDrive *ProtonDrive) ListDirectory(
ctx context.Context,
folderLinkID string) ([]*ProtonDirectoryData, error) {
ret := make([]*ProtonDirectoryData, 0)
folderLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, folderLinkID)
if err != nil {
return nil, err
}
if folderLink.State == proton.LinkStateActive {
childrenLinks, err := protonDrive.c.ListChildren(ctx, protonDrive.MainShare.ShareID, folderLink.LinkID, true)
if err != nil {
return nil, err
}
if childrenLinks != nil {
folderParentKR, err := protonDrive.getNodeKRByID(ctx, folderLink.ParentLinkID)
if err != nil {
return nil, err
}
defer folderParentKR.ClearPrivateParams()
folderLinkKR, err := folderLink.GetKeyRing(folderParentKR, protonDrive.AddrKR)
if err != nil {
return nil, err
}
defer folderLinkKR.ClearPrivateParams()
for i := range childrenLinks {
if childrenLinks[i].State != proton.LinkStateActive {
continue
}
name, err := childrenLinks[i].GetName(folderLinkKR, protonDrive.AddrKR)
if err != nil {
return nil, err
}
ret = append(ret, &ProtonDirectoryData{
Link: &childrenLinks[i],
Name: name,
IsFolder: childrenLinks[i].Type == proton.LinkTypeFolder,
})
}
}
}
return ret, nil
}
func (protonDrive *ProtonDrive) ListDirectoriesRecursively(
ctx context.Context,
parentNodeKR *crypto.KeyRing,
link *proton.Link,
download bool,
maxDepth, curDepth /* 0-based */ int,
excludeRoot bool,
pathSoFar string,
paths *[]string) error {
/*
Assumptions:
- we only care about the active ones
*/
if link.State != proton.LinkStateActive {
return nil
}
// log.Println("curDepth", curDepth, "pathSoFar", pathSoFar)
var currentPath = ""
if !(excludeRoot && curDepth == 0) {
name, err := link.GetName(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return err
}
currentPath = pathSoFar + "/" + name
// log.Println("currentPath", currentPath)
if paths != nil {
*paths = append(*paths, currentPath)
}
}
if download {
if protonDrive.Config.DataFolderName == "" {
return ErrDataFolderNameIsEmpty
}
if link.Type == proton.LinkTypeFile {
log.Println("Downloading", currentPath)
defer log.Println("Completes downloading", currentPath)
byteArray, _, err := protonDrive.DownloadFile(ctx, link)
if err != nil {
return err
}
err = os.WriteFile("./"+protonDrive.Config.DataFolderName+"/"+currentPath, byteArray, 0777)
if err != nil {
return err
}
} else /* folder */ {
if !(excludeRoot && curDepth == 0) {
// log.Println("Creating folder", currentPath)
// defer log.Println("Completes creating folder", currentPath)
err := os.Mkdir("./"+protonDrive.Config.DataFolderName+"/"+currentPath, 0777)
if err != nil {
return err
}
}
}
}
if maxDepth == -1 || curDepth < maxDepth {
if link.Type == proton.LinkTypeFolder {
childrenLinks, err := protonDrive.c.ListChildren(ctx, protonDrive.MainShare.ShareID, link.LinkID, true)
if err != nil {
return err
}
// log.Printf("childrenLinks len = %v, %#v", len(childrenLinks), childrenLinks)
if childrenLinks != nil {
// get current node's keyring
linkKR, err := link.GetKeyRing(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return err
}
defer linkKR.ClearPrivateParams()
for _, childLink := range childrenLinks {
err = protonDrive.ListDirectoriesRecursively(ctx, linkKR, &childLink, download, maxDepth, curDepth+1, excludeRoot, currentPath, paths)
if err != nil {
return err
}
}
}
}
}
return nil
}
func (protonDrive *ProtonDrive) CreateNewFolderByID(ctx context.Context, parentLinkID string, folderName string) (string, error) {
parentLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, parentLinkID)
if err != nil {
return "", err
}
return protonDrive.CreateNewFolder(ctx, &parentLink, folderName)
}
func (protonDrive *ProtonDrive) CreateNewFolder(ctx context.Context, parentLink *proton.Link, folderName string) (string, error) {
// TODO: check for duplicated folder name
parentNodeKR, err := protonDrive.getNodeKR(ctx, parentLink)
if err != nil {
return "", err
}
newNodeKey, newNodePassphraseEnc, newNodePassphraseSignature, err := generateNodeKeys(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return "", err
}
createFolderReq := proton.CreateFolderReq{
ParentLinkID: parentLink.LinkID,
// Name string
// Hash string
NodeKey: newNodeKey,
// NodeHashKey string
NodePassphrase: newNodePassphraseEnc,
NodePassphraseSignature: newNodePassphraseSignature,
SignatureAddress: protonDrive.signatureAddress,
}
/* Name is encrypted using the parent's keyring, and signed with address key */
err = createFolderReq.SetName(folderName, protonDrive.AddrKR, parentNodeKR)
if err != nil {
return "", err
}
parentHashKey, err := parentLink.GetHashKey(parentNodeKR)
if err != nil {
return "", err
}
err = createFolderReq.SetHash(folderName, parentHashKey)
if err != nil {
return "", err
}
newNodeKR, err := getKeyRing(parentNodeKR, protonDrive.AddrKR, newNodeKey, newNodePassphraseEnc, newNodePassphraseSignature)
if err != nil {
return "", err
}
err = createFolderReq.SetNodeHashKey(newNodeKR)
if err != nil {
return "", err
}
createFolderResp, err := protonDrive.c.CreateFolder(ctx, protonDrive.MainShare.ShareID, createFolderReq)
if err != nil {
return "", err
}
// log.Printf("createFolderResp %#v", createFolderResp)
return createFolderResp.ID, nil
}
func (protonDrive *ProtonDrive) MoveFileByID(ctx context.Context, srcLinkID, dstParentLinkID string, dstName string) error {
srcLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, srcLinkID)
if err != nil {
return err
}
if srcLink.State != proton.LinkStateActive {
return ErrLinkMustBeActive
}
dstParentLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, dstParentLinkID)
if err != nil {
return err
}
if dstParentLink.State != proton.LinkStateActive {
return ErrLinkMustBeActive
}
return protonDrive.MoveFile(ctx, &srcLink, &dstParentLink, dstName)
}
func (protonDrive *ProtonDrive) MoveFile(ctx context.Context, srcLink *proton.Link, dstParentLink *proton.Link, dstName string) error {
return protonDrive.MoveFolder(ctx, srcLink, dstParentLink, dstName)
}
func (protonDrive *ProtonDrive) MoveFolderByID(ctx context.Context, srcLinkID, dstParentLinkID, dstName string) error {
srcLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, srcLinkID)
if err != nil {
return err
}
if srcLink.State != proton.LinkStateActive {
return ErrLinkMustBeActive
}
dstParentLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, dstParentLinkID)
if err != nil {
return err
}
if dstParentLink.State != proton.LinkStateActive {
return ErrLinkMustBeActive
}
return protonDrive.MoveFolder(ctx, &srcLink, &dstParentLink, dstName)
}
func (protonDrive *ProtonDrive) MoveFolder(ctx context.Context, srcLink *proton.Link, dstParentLink *proton.Link, dstName string) error {
return protonDrive.moveLink(ctx, srcLink, dstParentLink, dstName)
}
func (protonDrive *ProtonDrive) moveLink(ctx context.Context, srcLink *proton.Link, dstParentLink *proton.Link, dstName string) error {
// we are moving the srcLink to under dstParentLink, with name dstName
req := proton.MoveLinkReq{
ParentLinkID: dstParentLink.LinkID,
OriginalHash: srcLink.Hash,
SignatureAddress: protonDrive.signatureAddress,
}
dstParentKR, err := protonDrive.getNodeKR(ctx, dstParentLink)
if err != nil {
return err
}
err = req.SetName(dstName, protonDrive.AddrKR, dstParentKR)
if err != nil {
return err
}
dstParentHashKey, err := dstParentLink.GetHashKey(dstParentKR)
if err != nil {
return err
}
err = req.SetHash(dstName, dstParentHashKey)
if err != nil {
return err
}
srcParentKR, err := protonDrive.getNodeKRByID(ctx, srcLink.ParentLinkID)
if err != nil {
return err
}
nodePassphrase, err := reencryptKeyPacket(srcParentKR, dstParentKR, protonDrive.AddrKR, srcLink.NodePassphrase)
if err != nil {
return err
}
req.NodePassphrase = nodePassphrase
req.NodePassphraseSignature = srcLink.NodePassphraseSignature
return protonDrive.c.MoveLink(ctx, protonDrive.MainShare.ShareID, srcLink.LinkID, req)
}

36
go.mod Normal file
View File

@@ -0,0 +1,36 @@
module github.com/henrybear327/Proton-API-Bridge
go 1.18
require (
github.com/ProtonMail/gopenpgp/v2 v2.7.1
github.com/gabriel-vasile/mimetype v1.4.2
github.com/henrybear327/go-proton-api v0.0.0-20230628220324-22ba21ecb67f
github.com/relvacode/iso8601 v1.3.0
)
require (
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.16.1-0.20230526091020-fb7689b15ae3 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230626094100-7e9e0395ebec // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/PuerkitoBio/goquery v1.8.1 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/bradenaw/juniper v0.13.0 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/emersion/go-message v0.16.0 // indirect
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
github.com/emersion/go-vcard v0.0.0-20230626131229-38c18b295bbd // indirect
github.com/go-resty/resty/v2 v2.7.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
golang.org/x/crypto v0.10.0 // indirect
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/net v0.11.0 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/text v0.10.0 // indirect
)

144
go.sum Normal file
View File

@@ -0,0 +1,144 @@
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/gluon v0.16.1-0.20230526091020-fb7689b15ae3 h1:HsRC3WKWY2xf3OGfXnVn1S/EhJx/8dKrWX4/JJQIBc8=
github.com/ProtonMail/gluon v0.16.1-0.20230526091020-fb7689b15ae3/go.mod h1:xYLE11dCH40RrNjkuncXZbYjGyuHKeFtdYKT2nkq6M8=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v0.0.0-20230626094100-7e9e0395ebec h1:vV3RryLxt42+ZIVOFbYJCH1jsZNTNmj2NYru5zfx+4E=
github.com/ProtonMail/go-crypto v0.0.0-20230626094100-7e9e0395ebec/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s=
github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/bradenaw/juniper v0.13.0 h1:KKMAiWDkRt45YUNzzw00Jec4nOgWDLVtztjf39E0ppI=
github.com/bradenaw/juniper v0.13.0/go.mod h1:Z2B7aJlQ7xbfWsnMLROj5t/5FQ94/MkIdKC30J4WvzI=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-message v0.16.0 h1:uZLz8ClLv3V5fSFF/fFdW9jXjrZkXIpE1Fn8fKx7pO4=
github.com/emersion/go-message v0.16.0/go.mod h1:pDJDgf/xeUIF+eicT6B/hPX/ZbEorKkUMPOxrPVG2eQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emersion/go-vcard v0.0.0-20230626131229-38c18b295bbd h1:n1kH4lDJLDgO8sqkt0QgeQXKims1L8khdgilk9G5lm8=
github.com/emersion/go-vcard v0.0.0-20230626131229-38c18b295bbd/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY=
github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/henrybear327/go-proton-api v0.0.0-20230628220324-22ba21ecb67f h1:OzLwkcwQZLkFnA0KpPVltWmw59Vq1XeJ9IzDjFvLdi4=
github.com/henrybear327/go-proton-api v0.0.0-20230628220324-22ba21ecb67f/go.mod h1:l42xBSOrCmkAxzWUHcoUsG/cP8m1hMhV72GoChOX3bg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/relvacode/iso8601 v1.3.0 h1:HguUjsGpIMh/zsTczGN3DVJFxTU/GX+MMmzcKoMO7ko=
github.com/relvacode/iso8601 v1.3.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

51
keyring.go Normal file
View File

@@ -0,0 +1,51 @@
package proton_api_bridge
import (
"context"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/henrybear327/go-proton-api"
)
func (protonDrive *ProtonDrive) getNodeKRByID(ctx context.Context, linkID string) (*crypto.KeyRing, error) {
if linkID == "" {
// most likely someone requested parent link, which happen to be ""
return protonDrive.MainShareKR, nil
}
link, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, linkID)
if err != nil {
return nil, err
}
return protonDrive.getNodeKR(ctx, &link)
}
func (protonDrive *ProtonDrive) getNodeKR(ctx context.Context, link *proton.Link) (*crypto.KeyRing, error) {
if link.ParentLinkID == "" {
nodeKR, err := link.GetKeyRing(protonDrive.MainShareKR, protonDrive.AddrKR)
if err != nil {
return nil, err
}
return nodeKR, nil
}
parentLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, link.ParentLinkID)
if err != nil {
return nil, err
}
// parentNodeKR is used to decrypt the current node's KR, as each node has its keyring, which can be decrypted by its parent
parentNodeKR, err := protonDrive.getNodeKR(ctx, &parentLink)
if err != nil {
return nil, err
}
nodeKR, err := link.GetKeyRing(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return nil, err
}
return nodeKR, nil
}

180
search.go Normal file
View File

@@ -0,0 +1,180 @@
package proton_api_bridge
import (
"context"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/henrybear327/go-proton-api"
)
/*
Observation: file name is unique, since it's checked (by hash) on the server
*/
func (protonDrive *ProtonDrive) SearchByNameRecursivelyFromRoot(ctx context.Context, targetName string, isFolder bool) (*proton.Link, error) {
var linkType proton.LinkType
if isFolder {
linkType = proton.LinkTypeFolder
} else {
linkType = proton.LinkTypeFile
}
return protonDrive.searchByNameRecursively(ctx, protonDrive.MainShareKR, protonDrive.RootLink, targetName, linkType)
}
func (protonDrive *ProtonDrive) SearchByNameRecursivelyByID(ctx context.Context, folderLinkID string, targetName string, isFolder bool) (*proton.Link, error) {
folderLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, folderLinkID)
if err != nil {
return nil, err
}
var linkType proton.LinkType
if isFolder {
linkType = proton.LinkTypeFolder
} else {
linkType = proton.LinkTypeFile
}
if folderLink.Type != proton.LinkTypeFolder {
return nil, ErrLinkTypeMustToBeFolderType
}
folderKeyRing, err := protonDrive.getNodeKRByID(ctx, folderLink.ParentLinkID)
if err != nil {
return nil, err
}
return protonDrive.searchByNameRecursively(ctx, folderKeyRing, &folderLink, targetName, linkType)
}
func (protonDrive *ProtonDrive) SearchByNameRecursively(ctx context.Context, folderLink *proton.Link, targetName string, isFolder bool) (*proton.Link, error) {
var linkType proton.LinkType
if isFolder {
linkType = proton.LinkTypeFolder
} else {
linkType = proton.LinkTypeFile
}
if folderLink.Type != proton.LinkTypeFolder {
return nil, ErrLinkTypeMustToBeFolderType
}
folderKeyRing, err := protonDrive.getNodeKRByID(ctx, folderLink.ParentLinkID)
if err != nil {
return nil, err
}
return protonDrive.searchByNameRecursively(ctx, folderKeyRing, folderLink, targetName, linkType)
}
func (protonDrive *ProtonDrive) searchByNameRecursively(
ctx context.Context,
parentNodeKR *crypto.KeyRing,
link *proton.Link,
targetName string,
linkType proton.LinkType) (*proton.Link, error) {
/*
Assumptions:
- we only care about the active ones
*/
if link.State != proton.LinkStateActive {
return nil, nil
}
name, err := link.GetName(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return nil, err
}
if link.Type == linkType && name == targetName {
return link, nil
}
if link.Type == proton.LinkTypeFolder {
childrenLinks, err := protonDrive.c.ListChildren(ctx, protonDrive.MainShare.ShareID, link.LinkID, true)
if err != nil {
return nil, err
}
// log.Printf("childrenLinks len = %v, %#v", len(childrenLinks), childrenLinks)
// get current node's keyring
linkKR, err := link.GetKeyRing(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return nil, err
}
defer linkKR.ClearPrivateParams()
for _, childLink := range childrenLinks {
ret, err := protonDrive.searchByNameRecursively(ctx, linkKR, &childLink, targetName, linkType)
if err != nil {
return nil, err
}
if ret != nil {
return ret, nil
}
}
}
return nil, nil
}
// if the target isn't found, nil will be returned for both return values
func (protonDrive *ProtonDrive) SearchByNameInFolderByID(ctx context.Context,
folderLinkID string,
targetName string,
searchForFile, searchForFolder bool) (*proton.Link, error) {
folderLink, err := protonDrive.c.GetLink(ctx, protonDrive.MainShare.ShareID, folderLinkID)
if err != nil {
return nil, err
}
return protonDrive.SearchByNameInFolder(ctx, &folderLink, targetName, searchForFile, searchForFolder)
}
func (protonDrive *ProtonDrive) SearchByNameInFolder(
ctx context.Context,
folderLink *proton.Link,
targetName string,
searchForFile, searchForFolder bool) (*proton.Link, error) {
if !searchForFile && !searchForFolder {
// nothing to search
return nil, nil
}
// we search all folders and files within this designated folder only
if folderLink.Type != proton.LinkTypeFolder {
return nil, ErrLinkTypeMustToBeFolderType
}
parentNodeKR, err := protonDrive.getNodeKRByID(ctx, folderLink.ParentLinkID)
if err != nil {
return nil, err
}
// get current node's keyring
folderLinkKR, err := folderLink.GetKeyRing(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return nil, err
}
defer folderLinkKR.ClearPrivateParams()
childrenLinks, err := protonDrive.c.ListChildren(ctx, protonDrive.MainShare.ShareID, folderLink.LinkID, true)
if err != nil {
return nil, err
}
for _, childLink := range childrenLinks {
if childLink.State != proton.LinkStateActive {
// we only search in the active folders
continue
}
name, err := childLink.GetName(folderLinkKR, protonDrive.AddrKR)
if err != nil {
return nil, err
}
if searchForFile && childLink.Type == proton.LinkTypeFile && name == targetName {
return &childLink, nil
} else if searchForFolder && childLink.Type == proton.LinkTypeFolder && name == targetName {
return &childLink, nil
}
}
return nil, nil
}

25
shares.go Normal file
View File

@@ -0,0 +1,25 @@
package proton_api_bridge
import (
"context"
"github.com/henrybear327/go-proton-api"
)
func getAllShares(ctx context.Context, c *proton.Client) ([]proton.ShareMetadata, error) {
shares, err := c.ListShares(ctx, true)
if err != nil {
return nil, err
}
return shares, nil
}
func getShareByID(ctx context.Context, c *proton.Client, shareID string) (*proton.Share, error) {
share, err := c.GetShare(ctx, shareID)
if err != nil {
return nil, err
}
return &share, nil
}

0
testcase/empty.txt Normal file
View File

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

9
utility/init.go Normal file
View File

@@ -0,0 +1,9 @@
package utility
import (
"log"
)
func SetupLog() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}

16
volumes.go Normal file
View File

@@ -0,0 +1,16 @@
package proton_api_bridge
import (
"context"
"github.com/henrybear327/go-proton-api"
)
func listAllVolumes(ctx context.Context, c *proton.Client) ([]proton.Volume, error) {
volumes, err := c.ListVolumes(ctx)
if err != nil {
return nil, err
}
return volumes, nil
}