Files
Proton-API-Bridge/file.go
2023-07-12 22:13:05 +02:00

580 lines
18 KiB
Go

package proton_api_bridge
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"io"
"mime"
"os"
"path/filepath"
"time"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"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.getLink(ctx, linkID)
if err != nil {
return nil, nil, err
}
return protonDrive.DownloadFile(ctx, link)
}
func (protonDrive *ProtonDrive) GetRevisions(ctx context.Context, link *proton.Link, revisionType proton.RevisionState) ([]*proton.RevisionMetadata, error) {
revisions, err := protonDrive.c.ListRevisions(ctx, protonDrive.MainShare.ShareID, link.LinkID)
if err != nil {
return nil, err
}
ret := make([]*proton.RevisionMetadata, 0)
// Revisions are only for files, they represent “versions” of files.
// Each file can have 1 active/draft revision and n obsolete revisions.
for i := range revisions {
if revisions[i].State == revisionType {
ret = append(ret, &revisions[i])
}
}
return ret, nil
}
func (protonDrive *ProtonDrive) GetActiveRevisionWithAttrs(ctx context.Context, link *proton.Link) (*proton.Revision, *FileSystemAttrs, error) {
if link == nil {
return nil, nil, ErrLinkMustNotBeNil
}
revisionsMetadata, err := protonDrive.GetRevisions(ctx, link, proton.RevisionStateActive)
if err != nil {
return nil, nil, err
}
if len(revisionsMetadata) != 1 {
return nil, nil, ErrCantFindActiveRevision
}
revision, err := protonDrive.c.GetRevisionAllBlocks(ctx, protonDrive.MainShare.ShareID, link.LinkID, revisionsMetadata[0].ID)
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, testParam int) (*proton.Link, int64, error) {
parentLink, err := protonDrive.getLink(ctx, parentLinkID)
if err != nil {
return nil, 0, err
}
return protonDrive.uploadFile(ctx, parentLink, filename, modTime, file, testParam)
}
func (protonDrive *ProtonDrive) UploadFileByPath(ctx context.Context, parentLink *proton.Link, filename string, filePath string, testParam int) (*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, testParam)
}
func (protonDrive *ProtonDrive) handleRevisionConflict(ctx context.Context, link *proton.Link, createFileResp *proton.CreateFileRes) (string, bool, error) {
if link != nil {
linkID := link.LinkID
draftRevision, err := protonDrive.GetRevisions(ctx, link, proton.RevisionStateDraft)
if err != nil {
return "", false, err
}
// if we have a draft revision, depending on the user config, we can abort the upload or recreate a draft
// if we have no draft revision, then we can create a new draft revision directly (there is a restriction of 1 draft revision per file)
if len(draftRevision) > 0 {
// TODO: maintain clientUID to mark that this is our own draft (which can indicate failed upload attempt!)
if protonDrive.Config.ReplaceExistingDraft {
// Question: how do we observe for file upload cancellation -> clientUID?
// Random thoughts: if there are concurrent modification to the draft, the server should be able to catch this when commiting the revision
// since the manifestSignature (hash) will fail to match
// delete the draft revision (will fail if the file only have a draft but no active revisions)
if link.State == proton.LinkStateDraft {
// delete the link (skipping trash, otherwise it won't work)
err = protonDrive.c.DeleteChildren(ctx, protonDrive.MainShare.ShareID, link.ParentLinkID, linkID)
if err != nil {
return "", false, err
}
return "", true, nil
}
// delete the draft revision
err = protonDrive.c.DeleteRevision(ctx, protonDrive.MainShare.ShareID, linkID, draftRevision[0].ID)
if err != nil {
return "", false, err
}
} else {
// if there is a draft, based on the web behavior, it will ask if the user wants to replace the failed upload attempt
// current behavior, we report an error to not upload the file (conservative)
return "", false, ErrDraftExists
}
}
// create a new revision
newRevision, err := protonDrive.c.CreateRevision(ctx, protonDrive.MainShare.ShareID, linkID)
if err != nil {
return "", false, err
}
return newRevision.ID, false, nil
} else if createFileResp != nil {
return createFileResp.RevisionID, false, nil
} else {
// should not happen anymore, since the file search will include the draft now
return "", false, ErrInternalErrorOnFileUpload
}
}
func (protonDrive *ProtonDrive) createFileUploadDraft(ctx context.Context, parentLink *proton.Link, filename string, modTime time.Time, mimeType string) (string, string, *crypto.SessionKey, *crypto.KeyRing, error) {
parentNodeKR, err := protonDrive.getNodeKR(ctx, parentLink)
if err != nil {
return "", "", nil, nil, err
}
newNodeKey, newNodePassphraseEnc, newNodePassphraseSignature, err := generateNodeKeys(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return "", "", 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, err
}
parentHashKey, err := parentLink.GetHashKey(parentNodeKR)
if err != nil {
return "", "", nil, nil, err
}
newNodeKR, err := getKeyRing(parentNodeKR, protonDrive.AddrKR, newNodeKey, newNodePassphraseEnc, newNodePassphraseSignature)
if err != nil {
return "", "", nil, nil, err
}
err = createFileReq.SetHash(filename, parentHashKey)
if err != nil {
return "", "", nil, nil, err
}
newSessionKey, err := createFileReq.SetContentKeyPacketAndSignature(newNodeKR, protonDrive.AddrKR)
if err != nil {
return "", "", nil, nil, err
}
createFileAction := func() (*proton.CreateFileRes, *proton.Link, error) {
createFileResp, err := protonDrive.c.CreateFile(ctx, protonDrive.MainShare.ShareID, createFileReq)
if err != nil {
// FIXME: check for duplicated filename by relying on checkAvailableHashes
// Also saving generating resources such as new nodeKR, etc.
if err != proton.ErrFileNameExist {
// other real error caught
return nil, nil, err
}
// search for the link within this folder which has an active/draft revision as we have a file creation conflict
link, err := protonDrive.SearchByNameInActiveFolder(ctx, parentLink, filename, true, false, proton.LinkStateActive)
if err != nil {
return nil, nil, err
}
if link == nil {
link, err = protonDrive.SearchByNameInActiveFolder(ctx, parentLink, filename, true, false, proton.LinkStateDraft)
if err != nil {
return nil, nil, err
}
if link == nil {
// we have a real problem here (unless the assumption is wrong)
// since we can't create a new file AND we can't locate a file with active/draft revision in it
return nil, nil, ErrCantLocateRevision
}
}
return nil, link, nil
}
return &createFileResp, nil, nil
}
createFileResp, link, err := createFileAction()
if err != nil {
return "", "", nil, nil, err
}
revisionID, shouldSubmitCreateFileRequestAgain, err := protonDrive.handleRevisionConflict(ctx, link, createFileResp)
if err != nil {
return "", "", nil, nil, err
}
if shouldSubmitCreateFileRequestAgain {
// the case where the link has only a draft but no active revision
// we need to delete the link and recreate one
createFileResp, link, err = createFileAction()
if err != nil {
return "", "", nil, nil, err
}
revisionID, _, err = protonDrive.handleRevisionConflict(ctx, link, createFileResp)
if err != nil {
return "", "", nil, nil, err
}
}
linkID := ""
if link != nil {
linkID = link.LinkID
// get original newSessionKey and newNodeKR
parentNodeKR, err = protonDrive.getNodeKRByID(ctx, link.ParentLinkID)
if err != nil {
return "", "", nil, nil, err
}
newNodeKR, err = link.GetKeyRing(parentNodeKR, protonDrive.AddrKR)
if err != nil {
return "", "", nil, nil, err
}
newSessionKey, err = link.GetSessionKey(protonDrive.AddrKR, newNodeKR)
if err != nil {
return "", "", nil, nil, err
}
} else {
linkID = createFileResp.ID
}
return linkID, revisionID, newSessionKey, newNodeKR, nil
}
func (protonDrive *ProtonDrive) uploadAndCollectBlockData(ctx context.Context, newSessionKey *crypto.SessionKey, newNodeKR *crypto.KeyRing, file io.Reader, linkID, revisionID string) ([]byte, int64, error) {
type PendingUploadBlocks struct {
blockUploadInfo proton.BlockUploadInfo
encData []byte
}
if newSessionKey == nil || newNodeKR == nil {
return nil, 0, ErrMissingInputUploadAndCollectBlockData
}
totalFileSize := int64(0)
pendingUploadBlocks := make([]PendingUploadBlocks, 0)
manifestSignatureData := make([]byte, 0)
uploadPendingBlocks := func() error {
if len(pendingUploadBlocks) == 0 {
return nil
}
blockList := make([]proton.BlockUploadInfo, 0)
for i := range pendingUploadBlocks {
blockList = append(blockList, pendingUploadBlocks[i].blockUploadInfo)
}
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 err
}
for i := range blockUploadResp {
err := protonDrive.c.UploadBlock(ctx, blockUploadResp[i].BareURL, blockUploadResp[i].Token, bytes.NewReader(pendingUploadBlocks[i].encData))
if err != nil {
return err
}
}
pendingUploadBlocks = pendingUploadBlocks[:0]
return nil
}
shouldContinue := true
for i := 1; shouldContinue; i++ {
// read at most data of size UPLOAD_BLOCK_SIZE
data := make([]byte, UPLOAD_BLOCK_SIZE) // FIXME: get block size from the server config instead of hardcoding it
readBytes, err := file.Read(data)
if err != nil {
if err == io.EOF {
// might still have data to read!
if readBytes == 0 {
break
}
shouldContinue = false
} else {
// all other errors
return nil, 0, err
}
}
data = data[:readBytes]
totalFileSize += int64(readBytes)
// encrypt data
dataPlainMessage := crypto.NewPlainMessage(data)
encData, err := newSessionKey.Encrypt(dataPlainMessage)
if err != nil {
return nil, 0, err
}
encSignature, err := protonDrive.AddrKR.SignDetachedEncrypted(dataPlainMessage, newNodeKR)
if err != nil {
return nil, 0, err
}
encSignatureStr, err := encSignature.GetArmored()
if err != nil {
return nil, 0, err
}
h := sha256.New()
h.Write(encData)
hash := h.Sum(nil)
base64Hash := base64.StdEncoding.EncodeToString(hash)
if err != nil {
return nil, 0, err
}
manifestSignatureData = append(manifestSignatureData, hash...)
pendingUploadBlocks = append(pendingUploadBlocks, PendingUploadBlocks{
blockUploadInfo: proton.BlockUploadInfo{
Index: i, // iOS drive: BE starts with 1
Size: int64(len(encData)),
EncSignature: encSignatureStr,
Hash: base64Hash,
},
encData: encData,
})
if (i-1) > 0 && (i-1)%UPLOAD_BATCH_BLOCK_SIZE == 0 {
err = uploadPendingBlocks()
if err != nil {
return nil, 0, err
}
}
}
err := uploadPendingBlocks()
if err != nil {
return nil, 0, err
}
return manifestSignatureData, totalFileSize, nil
}
func (protonDrive *ProtonDrive) commitNewRevision(ctx context.Context, nodeKR *crypto.KeyRing, modificationTime time.Time, size int64, manifestSignatureData []byte, linkID, revisionID string) error {
manifestSignature, err := protonDrive.AddrKR.SignDetached(crypto.NewPlainMessage(manifestSignatureData))
if err != nil {
return err
}
manifestSignatureString, err := manifestSignature.GetArmored()
if err != nil {
return err
}
commitRevisionReq := proton.CommitRevisionReq{
ManifestSignature: manifestSignatureString,
SignatureAddress: protonDrive.signatureAddress,
}
err = commitRevisionReq.SetEncXAttrString(protonDrive.AddrKR, nodeKR, modificationTime, size)
if err != nil {
return err
}
err = protonDrive.c.CommitRevision(ctx, protonDrive.MainShare.ShareID, linkID, revisionID, commitRevisionReq)
if err != nil {
return err
}
return nil
}
// testParam is for integration test only
// 0 = normal mode
// 1 = up to create revision
// 2 = up to block upload
func (protonDrive *ProtonDrive) uploadFile(ctx context.Context, parentLink *proton.Link, filename string, modTime time.Time, file io.Reader, testParam int) (*proton.Link, int64, error) {
// TODO: if we should use github.com/gabriel-vasile/mimetype to detect the MIME type from the file content itself
// Note: this approach might cause the upload progress to display the "fake" progress, since we read in all the content all-at-once
// mimetype.SetLimit(0)
// mType := mimetype.Detect(fileContent)
// mimeType := mType.String()
// detect MIME type by looking at the filename only
mimeType := mime.TypeByExtension(filepath.Ext(filename))
if mimeType == "" {
// api requires a mime type passed in
mimeType = "text/plain"
}
/* step 1: create a draft */
linkID, revisionID, newSessionKey, newNodeKR, err := protonDrive.createFileUploadDraft(ctx, parentLink, filename, modTime, mimeType)
if err != nil {
return nil, 0, err
}
if testParam == 1 {
// for integration tests
// we try to simulate only draft is created but no upload is performed yet
finalLink, err := protonDrive.getLink(ctx, linkID)
if err != nil {
return nil, 0, err
}
return finalLink, 0, nil
}
/* step 2: upload blocks and collect block data */
manifestSignature, fileSize, err := protonDrive.uploadAndCollectBlockData(ctx, newSessionKey, newNodeKR, file, linkID, revisionID)
if err != nil {
return nil, 0, err
}
if testParam == 2 {
// for integration tests
// we try to simulate blocks uploaded but not yet commited
finalLink, err := protonDrive.getLink(ctx, linkID)
if err != nil {
return nil, 0, err
}
return finalLink, 0, nil
}
/* step 3: mark the file as active by commiting the revision */
err = protonDrive.commitNewRevision(ctx, newNodeKR, modTime, fileSize, manifestSignature, linkID, revisionID)
if err != nil {
return nil, 0, err
}
finalLink, err := protonDrive.getLink(ctx, 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)
*/