feat(drivers): add ProtonDrive driver (#9331)

- Implement complete ProtonDrive storage driver with end-to-end encryption support
- Add authentication via username/password with credential caching and reusable login
- Support all core operations: List, Link, Put, Copy, Move, Remove, Rename, MakeDir
- Include encrypted file operations with PGP key management and node passphrase handling
- Add temporary HTTP server for secure file downloads with range request support
- Support media streaming using temp server range requests
- Implement progress tracking for uploads and downloads
- Support directory operations with circular move detection
- Add proper error handling and panic recovery for external library integration

Closes #9312
This commit is contained in:
D@' 3z K!7
2025-09-30 00:18:58 -06:00
committed by GitHub
parent fe564c42da
commit 81a7f28ba2
10 changed files with 1600 additions and 1 deletions

View File

@@ -0,0 +1,418 @@
package protondrive
/*
Package protondrive
Author: Da3zKi7<da3zki7@duck.com>
Date: 2025-09-18
Thanks to @henrybear327 for modded go-proton-api & Proton-API-Bridge
The power of open-source, the force of teamwork and the magic of reverse engineering!
D@' 3z K!7 - The King Of Cracking
Да здравствует Родина))
*/
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"sync"
"time"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/alist-org/alist/v3/internal/driver"
"github.com/alist-org/alist/v3/internal/errs"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/pkg/utils"
proton_api_bridge "github.com/henrybear327/Proton-API-Bridge"
"github.com/henrybear327/Proton-API-Bridge/common"
"github.com/henrybear327/go-proton-api"
)
type ProtonDrive struct {
model.Storage
Addition
protonDrive *proton_api_bridge.ProtonDrive
credentials *common.ProtonDriveCredential
apiBase string
appVersion string
protonJson string
userAgent string
sdkVersion string
webDriveAV string
tempServer *http.Server
tempServerPort int
downloadTokens map[string]*downloadInfo
tokenMutex sync.RWMutex
c *proton.Client
//m *proton.Manager
credentialCacheFile string
//userKR *crypto.KeyRing
addrKRs map[string]*crypto.KeyRing
addrData map[string]proton.Address
MainShare *proton.Share
RootLink *proton.Link
DefaultAddrKR *crypto.KeyRing
MainShareKR *crypto.KeyRing
}
func (d *ProtonDrive) Config() driver.Config {
return config
}
func (d *ProtonDrive) GetAddition() driver.Additional {
return &d.Addition
}
func (d *ProtonDrive) Init(ctx context.Context) error {
defer func() {
if r := recover(); r != nil {
fmt.Printf("ProtonDrive initialization panic: %v", r)
}
}()
if d.Username == "" {
return fmt.Errorf("username is required")
}
if d.Password == "" {
return fmt.Errorf("password is required")
}
//fmt.Printf("ProtonDrive Init: Username=%s, TwoFACode=%s", d.Username, d.TwoFACode)
if ctx == nil {
return fmt.Errorf("context cannot be nil")
}
cachedCredentials, err := d.loadCachedCredentials()
useReusableLogin := false
var reusableCredential *common.ReusableCredentialData
if err == nil && cachedCredentials != nil &&
cachedCredentials.UID != "" && cachedCredentials.AccessToken != "" &&
cachedCredentials.RefreshToken != "" && cachedCredentials.SaltedKeyPass != "" {
useReusableLogin = true
reusableCredential = cachedCredentials
} else {
useReusableLogin = false
reusableCredential = &common.ReusableCredentialData{}
}
config := &common.Config{
AppVersion: d.appVersion,
UserAgent: d.userAgent,
FirstLoginCredential: &common.FirstLoginCredentialData{
Username: d.Username,
Password: d.Password,
TwoFA: d.TwoFACode,
},
EnableCaching: true,
ConcurrentBlockUploadCount: 5,
ConcurrentFileCryptoCount: 2,
UseReusableLogin: false,
ReplaceExistingDraft: true,
ReusableCredential: reusableCredential,
CredentialCacheFile: d.credentialCacheFile,
}
if config.FirstLoginCredential == nil {
return fmt.Errorf("failed to create login credentials, FirstLoginCredential cannot be nil")
}
//fmt.Printf("Calling NewProtonDrive...")
protonDrive, credentials, err := proton_api_bridge.NewProtonDrive(
ctx,
config,
func(auth proton.Auth) {},
func() {},
)
if credentials == nil && !useReusableLogin {
return fmt.Errorf("failed to get credentials from NewProtonDrive")
}
if err != nil {
return fmt.Errorf("failed to initialize ProtonDrive: %w", err)
}
d.protonDrive = protonDrive
var finalCredentials *common.ProtonDriveCredential
if useReusableLogin {
// For reusable login, create credentials from cached data
finalCredentials = &common.ProtonDriveCredential{
UID: reusableCredential.UID,
AccessToken: reusableCredential.AccessToken,
RefreshToken: reusableCredential.RefreshToken,
SaltedKeyPass: reusableCredential.SaltedKeyPass,
}
d.credentials = finalCredentials
} else {
d.credentials = credentials
}
clientOptions := []proton.Option{
proton.WithAppVersion(d.appVersion),
proton.WithUserAgent(d.userAgent),
}
manager := proton.New(clientOptions...)
d.c = manager.NewClient(d.credentials.UID, d.credentials.AccessToken, d.credentials.RefreshToken)
saltedKeyPassBytes, err := base64.StdEncoding.DecodeString(d.credentials.SaltedKeyPass)
if err != nil {
return fmt.Errorf("failed to decode salted key pass: %w", err)
}
_, addrKRs, addrs, _, err := getAccountKRs(ctx, d.c, nil, saltedKeyPassBytes)
if err != nil {
return fmt.Errorf("failed to get account keyrings: %w", err)
}
d.MainShare = protonDrive.MainShare
d.RootLink = protonDrive.RootLink
d.MainShareKR = protonDrive.MainShareKR
d.DefaultAddrKR = protonDrive.DefaultAddrKR
d.addrKRs = addrKRs
d.addrData = addrs
return nil
}
func (d *ProtonDrive) Drop(ctx context.Context) error {
if d.tempServer != nil {
d.tempServer.Shutdown(ctx)
}
return nil
}
func (d *ProtonDrive) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
var linkID string
if dir.GetPath() == "/" {
linkID = d.protonDrive.RootLink.LinkID
} else {
link, err := d.searchByPath(ctx, dir.GetPath(), true)
if err != nil {
return nil, err
}
linkID = link.LinkID
}
entries, err := d.protonDrive.ListDirectory(ctx, linkID)
if err != nil {
return nil, fmt.Errorf("failed to list directory: %w", err)
}
//fmt.Printf("Found %d entries for path %s\n", len(entries), dir.GetPath())
//fmt.Printf("Found %d entries\n", len(entries))
if len(entries) == 0 {
emptySlice := []model.Obj{}
//fmt.Printf("Returning empty slice (entries): %+v\n", emptySlice)
return emptySlice, nil
}
var objects []model.Obj
for _, entry := range entries {
obj := &model.Object{
Name: entry.Name,
Size: entry.Link.Size,
Modified: time.Unix(entry.Link.ModifyTime, 0),
IsFolder: entry.IsFolder,
}
objects = append(objects, obj)
}
return objects, nil
}
func (d *ProtonDrive) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
link, err := d.searchByPath(ctx, file.GetPath(), false)
if err != nil {
return nil, err
}
if err := d.ensureTempServer(); err != nil {
return nil, fmt.Errorf("failed to start temp server: %w", err)
}
token := d.generateDownloadToken(link.LinkID, file.GetName())
/* return &model.Link{
URL: fmt.Sprintf("protondrive://download/%s", link.LinkID),
}, nil */
return &model.Link{
URL: fmt.Sprintf("http://localhost:%d/temp/%s", d.tempServerPort, token),
}, nil
}
func (d *ProtonDrive) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
var parentLinkID string
if parentDir.GetPath() == "/" {
parentLinkID = d.protonDrive.RootLink.LinkID
} else {
link, err := d.searchByPath(ctx, parentDir.GetPath(), true)
if err != nil {
return nil, err
}
parentLinkID = link.LinkID
}
_, err := d.protonDrive.CreateNewFolderByID(ctx, parentLinkID, dirName)
if err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
newDir := &model.Object{
Name: dirName,
IsFolder: true,
Modified: time.Now(),
}
return newDir, nil
}
func (d *ProtonDrive) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
return d.DirectMove(ctx, srcObj, dstDir)
}
func (d *ProtonDrive) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
if d.protonDrive == nil {
return nil, fmt.Errorf("protonDrive bridge is nil")
}
return d.DirectRename(ctx, srcObj, newName)
}
func (d *ProtonDrive) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
if srcObj.IsDir() {
return nil, fmt.Errorf("directory copy not supported")
}
srcLink, err := d.searchByPath(ctx, srcObj.GetPath(), false)
if err != nil {
return nil, err
}
reader, linkSize, fileSystemAttrs, err := d.protonDrive.DownloadFile(ctx, srcLink, 0)
if err != nil {
return nil, fmt.Errorf("failed to download source file: %w", err)
}
defer reader.Close()
actualSize := linkSize
if fileSystemAttrs != nil && fileSystemAttrs.Size > 0 {
actualSize = fileSystemAttrs.Size
}
tempFile, err := utils.CreateTempFile(reader, actualSize)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer tempFile.Close()
updatedObj := &model.Object{
Name: srcObj.GetName(),
// Use the accurate and real size
Size: actualSize,
Modified: srcObj.ModTime(),
IsFolder: false,
}
return d.Put(ctx, dstDir, &fileStreamer{
ReadCloser: tempFile,
obj: updatedObj,
}, nil)
}
func (d *ProtonDrive) Remove(ctx context.Context, obj model.Obj) error {
link, err := d.searchByPath(ctx, obj.GetPath(), obj.IsDir())
if err != nil {
return err
}
if obj.IsDir() {
return d.protonDrive.MoveFolderToTrashByID(ctx, link.LinkID, false)
} else {
return d.protonDrive.MoveFileToTrashByID(ctx, link.LinkID)
}
}
func (d *ProtonDrive) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
var parentLinkID string
if dstDir.GetPath() == "/" {
parentLinkID = d.protonDrive.RootLink.LinkID
} else {
link, err := d.searchByPath(ctx, dstDir.GetPath(), true)
if err != nil {
return nil, err
}
parentLinkID = link.LinkID
}
tempFile, err := utils.CreateTempFile(file, file.GetSize())
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer tempFile.Close()
err = d.uploadFile(ctx, parentLinkID, file.GetName(), tempFile, file.GetSize(), up)
if err != nil {
return nil, err
}
uploadedObj := &model.Object{
Name: file.GetName(),
Size: file.GetSize(),
Modified: file.ModTime(),
IsFolder: false,
}
return uploadedObj, nil
}
func (d *ProtonDrive) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) {
// TODO get archive file meta-info, return errs.NotImplement to use an internal archive tool, optional
return nil, errs.NotImplement
}
func (d *ProtonDrive) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) {
// TODO list args.InnerPath in the archive obj, return errs.NotImplement to use an internal archive tool, optional
return nil, errs.NotImplement
}
func (d *ProtonDrive) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) {
// TODO return link of file args.InnerPath in the archive obj, return errs.NotImplement to use an internal archive tool, optional
return nil, errs.NotImplement
}
func (d *ProtonDrive) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) {
// TODO extract args.InnerPath path in the archive srcObj to the dstDir location, optional
// a folder with the same name as the archive file needs to be created to store the extracted results if args.PutIntoNewDir
// return errs.NotImplement to use an internal archive tool
return nil, errs.NotImplement
}
var _ driver.Driver = (*ProtonDrive)(nil)

View File

@@ -0,0 +1,69 @@
package protondrive
/*
Package protondrive
Author: Da3zKi7<da3zki7@duck.com>
Date: 2025-09-18
Thanks to @henrybear327 for modded go-proton-api & Proton-API-Bridge
The power of open-source, the force of teamwork and the magic of reverse engineering!
D@' 3z K!7 - The King Of Cracking
Да здравствует Родина))
*/
import (
"github.com/alist-org/alist/v3/internal/driver"
"github.com/alist-org/alist/v3/internal/op"
)
type Addition struct {
driver.RootPath
//driver.RootID
Username string `json:"username" required:"true" type:"string"`
Password string `json:"password" required:"true" type:"string"`
TwoFACode string `json:"two_fa_code,omitempty" type:"string"`
}
type Config struct {
Name string `json:"name"`
LocalSort bool `json:"local_sort"`
OnlyLocal bool `json:"only_local"`
OnlyProxy bool `json:"only_proxy"`
NoCache bool `json:"no_cache"`
NoUpload bool `json:"no_upload"`
NeedMs bool `json:"need_ms"`
DefaultRoot string `json:"default_root"`
}
var config = driver.Config{
Name: "ProtonDrive",
LocalSort: false,
OnlyLocal: false,
OnlyProxy: false,
NoCache: false,
NoUpload: false,
NeedMs: false,
DefaultRoot: "/",
CheckStatus: false,
Alert: "",
NoOverwriteUpload: false,
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &ProtonDrive{
apiBase: "https://drive.proton.me/api",
appVersion: "windows-drive@1.11.3+rclone+proton",
credentialCacheFile: ".prtcrd",
protonJson: "application/vnd.protonmail.v1+json",
sdkVersion: "js@0.3.0",
userAgent: "ProtonDrive/v1.70.0 (Windows NT 10.0.22000; Win64; x64)",
webDriveAV: "web-drive@5.2.0+0f69f7a8",
}
})
}

View File

@@ -0,0 +1,124 @@
package protondrive
/*
Package protondrive
Author: Da3zKi7<da3zki7@duck.com>
Date: 2025-09-18
Thanks to @henrybear327 for modded go-proton-api & Proton-API-Bridge
The power of open-source, the force of teamwork and the magic of reverse engineering!
D@' 3z K!7 - The King Of Cracking
Да здравствует Родина))
*/
import (
"errors"
"io"
"os"
"time"
"github.com/alist-org/alist/v3/internal/driver"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/pkg/http_range"
"github.com/alist-org/alist/v3/pkg/utils"
"github.com/henrybear327/go-proton-api"
)
type ProtonFile struct {
*proton.Link
Name string
IsFolder bool
}
func (p *ProtonFile) GetName() string {
return p.Name
}
func (p *ProtonFile) GetSize() int64 {
return p.Link.Size
}
func (p *ProtonFile) GetPath() string {
return p.Name
}
func (p *ProtonFile) IsDir() bool {
return p.IsFolder
}
func (p *ProtonFile) ModTime() time.Time {
return time.Unix(p.Link.ModifyTime, 0)
}
func (p *ProtonFile) CreateTime() time.Time {
return time.Unix(p.Link.CreateTime, 0)
}
type downloadInfo struct {
LinkID string
FileName string
}
type fileStreamer struct {
io.ReadCloser
obj model.Obj
}
func (fs *fileStreamer) GetMimetype() string { return "" }
func (fs *fileStreamer) NeedStore() bool { return false }
func (fs *fileStreamer) IsForceStreamUpload() bool { return false }
func (fs *fileStreamer) GetExist() model.Obj { return nil }
func (fs *fileStreamer) SetExist(model.Obj) {}
func (fs *fileStreamer) RangeRead(http_range.Range) (io.Reader, error) {
return nil, errors.New("not supported")
}
func (fs *fileStreamer) CacheFullInTempFile() (model.File, error) {
return nil, errors.New("not supported")
}
func (fs *fileStreamer) SetTmpFile(r *os.File) {}
func (fs *fileStreamer) GetFile() model.File { return nil }
func (fs *fileStreamer) GetName() string { return fs.obj.GetName() }
func (fs *fileStreamer) GetSize() int64 { return fs.obj.GetSize() }
func (fs *fileStreamer) GetPath() string { return fs.obj.GetPath() }
func (fs *fileStreamer) IsDir() bool { return fs.obj.IsDir() }
func (fs *fileStreamer) ModTime() time.Time { return fs.obj.ModTime() }
func (fs *fileStreamer) CreateTime() time.Time { return fs.obj.ModTime() }
func (fs *fileStreamer) GetHash() utils.HashInfo { return fs.obj.GetHash() }
func (fs *fileStreamer) GetID() string { return fs.obj.GetID() }
type httpRange struct {
start, end int64
}
type MoveRequest struct {
ParentLinkID string `json:"ParentLinkID"`
NodePassphrase string `json:"NodePassphrase"`
NodePassphraseSignature *string `json:"NodePassphraseSignature"`
Name string `json:"Name"`
NameSignatureEmail string `json:"NameSignatureEmail"`
Hash string `json:"Hash"`
OriginalHash string `json:"OriginalHash"`
ContentHash *string `json:"ContentHash"` // Maybe null
}
type progressReader struct {
reader io.Reader
total int64
current int64
callback driver.UpdateProgress
}
type RenameRequest struct {
Name string `json:"Name"` // PGP encrypted name
NameSignatureEmail string `json:"NameSignatureEmail"` // User's signature email
Hash string `json:"Hash"` // New name hash
OriginalHash string `json:"OriginalHash"` // Current name hash
}
type RenameResponse struct {
Code int `json:"Code"`
}

View File

@@ -0,0 +1,918 @@
package protondrive
/*
Package protondrive
Author: Da3zKi7<da3zki7@duck.com>
Date: 2025-09-18
Thanks to @henrybear327 for modded go-proton-api & Proton-API-Bridge
The power of open-source, the force of teamwork and the magic of reverse engineering!
D@' 3z K!7 - The King Of Cracking
Да здравствует Родина))
*/
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/alist-org/alist/v3/internal/driver"
"github.com/alist-org/alist/v3/internal/model"
"github.com/henrybear327/Proton-API-Bridge/common"
"github.com/henrybear327/go-proton-api"
)
func (d *ProtonDrive) loadCachedCredentials() (*common.ReusableCredentialData, error) {
if d.credentialCacheFile == "" {
return nil, nil
}
if _, err := os.Stat(d.credentialCacheFile); os.IsNotExist(err) {
return nil, nil
}
data, err := os.ReadFile(d.credentialCacheFile)
if err != nil {
return nil, fmt.Errorf("failed to read credential cache file: %w", err)
}
var credentials common.ReusableCredentialData
if err := json.Unmarshal(data, &credentials); err != nil {
return nil, fmt.Errorf("failed to parse cached credentials: %w", err)
}
if credentials.UID == "" || credentials.AccessToken == "" ||
credentials.RefreshToken == "" || credentials.SaltedKeyPass == "" {
return nil, fmt.Errorf("cached credentials are incomplete")
}
return &credentials, nil
}
func (d *ProtonDrive) searchByPath(ctx context.Context, fullPath string, isFolder bool) (*proton.Link, error) {
if fullPath == "/" {
return d.protonDrive.RootLink, nil
}
cleanPath := strings.Trim(fullPath, "/")
pathParts := strings.Split(cleanPath, "/")
currentLink := d.protonDrive.RootLink
for i, part := range pathParts {
isLastPart := i == len(pathParts)-1
searchForFolder := !isLastPart || isFolder
entries, err := d.protonDrive.ListDirectory(ctx, currentLink.LinkID)
if err != nil {
return nil, fmt.Errorf("failed to list directory: %w", err)
}
found := false
for _, entry := range entries {
// entry.Name is already decrypted!
if entry.Name == part && entry.IsFolder == searchForFolder {
currentLink = entry.Link
found = true
break
}
}
if !found {
return nil, fmt.Errorf("path not found: %s (looking for part: %s)", fullPath, part)
}
}
return currentLink, nil
}
func (pr *progressReader) Read(p []byte) (int, error) {
n, err := pr.reader.Read(p)
pr.current += int64(n)
if pr.callback != nil {
percentage := float64(pr.current) / float64(pr.total) * 100
pr.callback(percentage)
}
return n, err
}
func (d *ProtonDrive) uploadFile(ctx context.Context, parentLinkID, fileName string, file *os.File, size int64, up driver.UpdateProgress) error {
fileInfo, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to get file info: %w", err)
}
_, err = d.protonDrive.GetLink(ctx, parentLinkID)
if err != nil {
return fmt.Errorf("failed to get parent link: %w", err)
}
reader := &progressReader{
reader: bufio.NewReader(file),
total: size,
current: 0,
callback: up,
}
_, _, err = d.protonDrive.UploadFileByReader(ctx, parentLinkID, fileName, fileInfo.ModTime(), reader, 0)
if err != nil {
return fmt.Errorf("failed to upload file: %w", err)
}
return nil
}
func (d *ProtonDrive) ensureTempServer() error {
if d.tempServer != nil {
// Already running
return nil
}
listener, err := net.Listen("tcp", ":0")
if err != nil {
return err
}
d.tempServerPort = listener.Addr().(*net.TCPAddr).Port
mux := http.NewServeMux()
mux.HandleFunc("/temp/", d.handleTempDownload)
d.tempServer = &http.Server{
Handler: mux,
}
go func() {
d.tempServer.Serve(listener)
}()
return nil
}
func (d *ProtonDrive) handleTempDownload(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.URL.Path, "/temp/")
d.tokenMutex.RLock()
info, exists := d.downloadTokens[token]
d.tokenMutex.RUnlock()
if !exists {
http.Error(w, "Invalid or expired token", http.StatusNotFound)
return
}
link, err := d.protonDrive.GetLink(r.Context(), info.LinkID)
if err != nil {
http.Error(w, "Failed to get file link", http.StatusInternalServerError)
return
}
// Get file size for range calculations
_, _, attrs, err := d.protonDrive.DownloadFile(r.Context(), link, 0)
if err != nil {
http.Error(w, "Failed to get file info", http.StatusInternalServerError)
return
}
fileSize := attrs.Size
rangeHeader := r.Header.Get("Range")
if rangeHeader != "" {
// Parse range header like "bytes=0-1023" or "bytes=1024-"
ranges, err := parseRange(rangeHeader, fileSize)
if err != nil {
http.Error(w, "Invalid range", http.StatusRequestedRangeNotSatisfiable)
return
}
if len(ranges) == 1 {
// Single range request, small
start, end := ranges[0].start, ranges[0].end
contentLength := end - start + 1
// Start download from offset
reader, _, _, err := d.protonDrive.DownloadFile(r.Context(), link, start)
if err != nil {
http.Error(w, "Failed to start download", http.StatusInternalServerError)
return
}
defer reader.Close()
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize))
w.Header().Set("Content-Length", fmt.Sprintf("%d", contentLength))
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(link.Name)))
// Partial content...
// Setting fileName is more cosmetical here
//.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", link.Name))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", info.FileName))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusPartialContent)
io.CopyN(w, reader, contentLength)
return
}
}
// Full file download (non-range request)
reader, _, _, err := d.protonDrive.DownloadFile(r.Context(), link, 0)
if err != nil {
http.Error(w, "Failed to start download", http.StatusInternalServerError)
return
}
defer reader.Close()
// Set headers for full content
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize))
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(link.Name)))
// Setting fileName is needed since ProtonDrive fileName is more like a random string
//w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", link.Name))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", info.FileName))
w.Header().Set("Accept-Ranges", "bytes")
// Stream the full file
io.Copy(w, reader)
}
func (d *ProtonDrive) generateDownloadToken(linkID, fileName string) string {
token := fmt.Sprintf("%d_%s", time.Now().UnixNano(), linkID[:8])
d.tokenMutex.Lock()
if d.downloadTokens == nil {
d.downloadTokens = make(map[string]*downloadInfo)
}
d.downloadTokens[token] = &downloadInfo{
LinkID: linkID,
FileName: fileName,
}
d.tokenMutex.Unlock()
go func() {
// Token expires in 1 hour
time.Sleep(1 * time.Hour)
d.tokenMutex.Lock()
delete(d.downloadTokens, token)
d.tokenMutex.Unlock()
}()
return token
}
func parseRange(rangeHeader string, size int64) ([]httpRange, error) {
if !strings.HasPrefix(rangeHeader, "bytes=") {
return nil, fmt.Errorf("invalid range header")
}
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
ranges := strings.Split(rangeSpec, ",")
var result []httpRange
for _, r := range ranges {
r = strings.TrimSpace(r)
if strings.Contains(r, "-") {
parts := strings.Split(r, "-")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid range format")
}
var start, end int64
var err error
if parts[0] == "" {
// Suffix range (e.g., "-500")
if parts[1] == "" {
return nil, fmt.Errorf("invalid range format")
}
end = size - 1
start, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return nil, err
}
start = size - start
if start < 0 {
start = 0
}
} else if parts[1] == "" {
// Prefix range (e.g., "500-")
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return nil, err
}
end = size - 1
} else {
// Full range (e.g., "0-1023")
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return nil, err
}
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return nil, err
}
}
if start >= size || end >= size || start > end {
return nil, fmt.Errorf("range out of bounds")
}
result = append(result, httpRange{start: start, end: end})
}
}
return result, nil
}
func (d *ProtonDrive) encryptFileName(ctx context.Context, name string, parentLinkID string) (string, error) {
parentLink, err := d.getLink(ctx, parentLinkID)
if err != nil {
return "", fmt.Errorf("failed to get parent link: %w", err)
}
// Get parent node keyring
parentNodeKR, err := d.getLinkKR(ctx, parentLink)
if err != nil {
return "", fmt.Errorf("failed to get parent keyring: %w", err)
}
// Temporary file (request)
tempReq := proton.CreateFileReq{
SignatureAddress: d.MainShare.Creator,
}
// Encrypt the filename
err = tempReq.SetName(name, d.DefaultAddrKR, parentNodeKR)
if err != nil {
return "", fmt.Errorf("failed to encrypt filename: %w", err)
}
return tempReq.Name, nil
}
func (d *ProtonDrive) generateFileNameHash(ctx context.Context, name string, parentLinkID string) (string, error) {
parentLink, err := d.getLink(ctx, parentLinkID)
if err != nil {
return "", fmt.Errorf("failed to get parent link: %w", err)
}
// Get parent node keyring
parentNodeKR, err := d.getLinkKR(ctx, parentLink)
if err != nil {
return "", fmt.Errorf("failed to get parent keyring: %w", err)
}
signatureVerificationKR, err := d.getSignatureVerificationKeyring([]string{parentLink.SignatureEmail}, parentNodeKR)
if err != nil {
return "", fmt.Errorf("failed to get signature verification keyring: %w", err)
}
parentHashKey, err := parentLink.GetHashKey(parentNodeKR, signatureVerificationKR)
if err != nil {
return "", fmt.Errorf("failed to get parent hash key: %w", err)
}
nameHash, err := proton.GetNameHash(name, parentHashKey)
if err != nil {
return "", fmt.Errorf("failed to generate name hash: %w", err)
}
return nameHash, nil
}
func (d *ProtonDrive) getOriginalNameHash(link *proton.Link) (string, error) {
if link == nil {
return "", fmt.Errorf("link cannot be nil")
}
if link.Hash == "" {
return "", fmt.Errorf("link hash is empty")
}
return link.Hash, nil
}
func (d *ProtonDrive) getLink(ctx context.Context, linkID string) (*proton.Link, error) {
if linkID == "" {
return nil, fmt.Errorf("linkID cannot be empty")
}
link, err := d.c.GetLink(ctx, d.MainShare.ShareID, linkID)
if err != nil {
return nil, err
}
return &link, nil
}
func (d *ProtonDrive) getLinkKR(ctx context.Context, link *proton.Link) (*crypto.KeyRing, error) {
if link == nil {
return nil, fmt.Errorf("link cannot be nil")
}
// Root Link or Root Dir
if link.ParentLinkID == "" {
signatureVerificationKR, err := d.getSignatureVerificationKeyring([]string{link.SignatureEmail})
if err != nil {
return nil, err
}
return link.GetKeyRing(d.MainShareKR, signatureVerificationKR)
}
// Get parent keyring recursively
parentLink, err := d.getLink(ctx, link.ParentLinkID)
if err != nil {
return nil, err
}
parentNodeKR, err := d.getLinkKR(ctx, parentLink)
if err != nil {
return nil, err
}
signatureVerificationKR, err := d.getSignatureVerificationKeyring([]string{link.SignatureEmail})
if err != nil {
return nil, err
}
return link.GetKeyRing(parentNodeKR, signatureVerificationKR)
}
var (
ErrKeyPassOrSaltedKeyPassMustBeNotNil = errors.New("either keyPass or saltedKeyPass must be not nil")
ErrFailedToUnlockUserKeys = errors.New("failed to unlock user keys")
)
func getAccountKRs(ctx context.Context, c *proton.Client, keyPass, saltedKeyPass []byte) (*crypto.KeyRing, map[string]*crypto.KeyRing, map[string]proton.Address, []byte, error) {
user, err := c.GetUser(ctx)
if err != nil {
return nil, nil, nil, nil, err
}
// fmt.Printf("user %#v", user)
addrsArr, err := c.GetAddresses(ctx)
if err != nil {
return nil, nil, nil, nil, err
}
// fmt.Printf("addr %#v", addr)
if saltedKeyPass == nil {
if keyPass == nil {
return nil, nil, nil, nil, ErrKeyPassOrSaltedKeyPassMustBeNotNil
}
// Due to limitations, salts are stored using cacheCredentialToFile
salts, err := c.GetSalts(ctx)
if err != nil {
return nil, nil, nil, nil, err
}
// fmt.Printf("salts %#v", salts)
saltedKeyPass, err = salts.SaltForKey(keyPass, user.Keys.Primary().ID)
if err != nil {
return nil, nil, nil, nil, err
}
// fmt.Printf("saltedKeyPass ok")
}
userKR, addrKRs, err := proton.Unlock(user, addrsArr, saltedKeyPass, nil)
if err != nil {
return nil, nil, nil, nil, err
} else if userKR.CountDecryptionEntities() == 0 {
return nil, nil, nil, nil, ErrFailedToUnlockUserKeys
}
addrs := make(map[string]proton.Address)
for _, addr := range addrsArr {
addrs[addr.Email] = addr
}
return userKR, addrKRs, addrs, saltedKeyPass, nil
}
func (d *ProtonDrive) getSignatureVerificationKeyring(emailAddresses []string, verificationAddrKRs ...*crypto.KeyRing) (*crypto.KeyRing, error) {
ret, err := crypto.NewKeyRing(nil)
if err != nil {
return nil, err
}
for _, emailAddress := range emailAddresses {
if addr, ok := d.addrData[emailAddress]; ok {
if addrKR, exists := d.addrKRs[addr.ID]; exists {
err = d.addKeysFromKR(ret, addrKR)
if err != nil {
return nil, err
}
}
}
}
for _, kr := range verificationAddrKRs {
err = d.addKeysFromKR(ret, kr)
if err != nil {
return nil, err
}
}
if ret.CountEntities() == 0 {
return nil, fmt.Errorf("no keyring for signature verification")
}
return ret, nil
}
func (d *ProtonDrive) addKeysFromKR(kr *crypto.KeyRing, newKRs ...*crypto.KeyRing) error {
for i := range newKRs {
for _, key := range newKRs[i].GetKeys() {
err := kr.AddKey(key)
if err != nil {
return err
}
}
}
return nil
}
func (d *ProtonDrive) DirectRename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
//fmt.Printf("DEBUG DirectRename: path=%s, newName=%s", srcObj.GetPath(), newName)
if d.MainShare == nil || d.DefaultAddrKR == nil {
return nil, fmt.Errorf("missing required fields: MainShare=%v, DefaultAddrKR=%v",
d.MainShare != nil, d.DefaultAddrKR != nil)
}
if d.protonDrive == nil {
return nil, fmt.Errorf("protonDrive bridge is nil")
}
srcLink, err := d.searchByPath(ctx, srcObj.GetPath(), srcObj.IsDir())
if err != nil {
return nil, fmt.Errorf("failed to find source: %w", err)
}
parentLinkID := srcLink.ParentLinkID
if parentLinkID == "" {
return nil, fmt.Errorf("cannot rename root folder")
}
encryptedName, err := d.encryptFileName(ctx, newName, parentLinkID)
if err != nil {
return nil, fmt.Errorf("failed to encrypt filename: %w", err)
}
newHash, err := d.generateFileNameHash(ctx, newName, parentLinkID)
if err != nil {
return nil, fmt.Errorf("failed to generate new hash: %w", err)
}
originalHash, err := d.getOriginalNameHash(srcLink)
if err != nil {
return nil, fmt.Errorf("failed to get original hash: %w", err)
}
renameReq := RenameRequest{
Name: encryptedName,
NameSignatureEmail: d.MainShare.Creator,
Hash: newHash,
OriginalHash: originalHash,
}
err = d.executeRenameAPI(ctx, srcLink.LinkID, renameReq)
if err != nil {
return nil, fmt.Errorf("rename API call failed: %w", err)
}
return &model.Object{
Name: newName,
Size: srcObj.GetSize(),
Modified: srcObj.ModTime(),
IsFolder: srcObj.IsDir(),
}, nil
}
func (d *ProtonDrive) executeRenameAPI(ctx context.Context, linkID string, req RenameRequest) error {
renameURL := fmt.Sprintf(d.apiBase+"/drive/v2/volumes/%s/links/%s/rename",
d.MainShare.VolumeID, linkID)
reqBody, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal rename request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "PUT", renameURL, bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("failed to create HTTP request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", d.protonJson)
httpReq.Header.Set("X-Pm-Appversion", d.webDriveAV)
httpReq.Header.Set("X-Pm-Drive-Sdk-Version", d.sdkVersion)
httpReq.Header.Set("X-Pm-Uid", d.credentials.UID)
httpReq.Header.Set("Authorization", "Bearer "+d.credentials.AccessToken)
client := &http.Client{}
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to execute rename request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("rename failed with status %d", resp.StatusCode)
}
var renameResp RenameResponse
if err := json.NewDecoder(resp.Body).Decode(&renameResp); err != nil {
return fmt.Errorf("failed to decode rename response: %w", err)
}
if renameResp.Code != 1000 {
return fmt.Errorf("rename failed with code %d", renameResp.Code)
}
return nil
}
func (d *ProtonDrive) executeMoveAPI(ctx context.Context, linkID string, req MoveRequest) error {
//fmt.Printf("DEBUG Move Request - Name: %s\n", req.Name)
//fmt.Printf("DEBUG Move Request - Hash: %s\n", req.Hash)
//fmt.Printf("DEBUG Move Request - OriginalHash: %s\n", req.OriginalHash)
//fmt.Printf("DEBUG Move Request - ParentLinkID: %s\n", req.ParentLinkID)
//fmt.Printf("DEBUG Move Request - Name length: %d\n", len(req.Name))
//fmt.Printf("DEBUG Move Request - NameSignatureEmail: %s\n", req.NameSignatureEmail)
//fmt.Printf("DEBUG Move Request - ContentHash: %v\n", req.ContentHash)
//fmt.Printf("DEBUG Move Request - NodePassphrase length: %d\n", len(req.NodePassphrase))
//fmt.Printf("DEBUG Move Request - NodePassphraseSignature length: %d\n", len(req.NodePassphraseSignature))
//fmt.Printf("DEBUG Move Request - SrcLinkID: %s\n", linkID)
//fmt.Printf("DEBUG Move Request - DstParentLinkID: %s\n", req.ParentLinkID)
//fmt.Printf("DEBUG Move Request - ShareID: %s\n", d.MainShare.ShareID)
srcLink, _ := d.getLink(ctx, linkID)
if srcLink != nil && srcLink.ParentLinkID == req.ParentLinkID {
return fmt.Errorf("cannot move to same parent directory")
}
moveURL := fmt.Sprintf(d.apiBase+"/drive/v2/volumes/%s/links/%s/move",
d.MainShare.VolumeID, linkID)
reqBody, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal move request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "PUT", moveURL, bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("failed to create HTTP request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+d.credentials.AccessToken)
httpReq.Header.Set("Accept", d.protonJson)
httpReq.Header.Set("X-Pm-Appversion", d.webDriveAV)
httpReq.Header.Set("X-Pm-Drive-Sdk-Version", d.sdkVersion)
httpReq.Header.Set("X-Pm-Uid", d.credentials.UID)
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to execute move request: %w", err)
}
defer resp.Body.Close()
var moveResp RenameResponse
if err := json.NewDecoder(resp.Body).Decode(&moveResp); err != nil {
return fmt.Errorf("failed to decode move response: %w", err)
}
if moveResp.Code != 1000 {
return fmt.Errorf("move operation failed with code: %d", moveResp.Code)
}
return nil
}
func (d *ProtonDrive) DirectMove(ctx context.Context, srcObj model.Obj, dstDir model.Obj) (model.Obj, error) {
//fmt.Printf("DEBUG DirectMove: srcPath=%s, dstPath=%s", srcObj.GetPath(), dstDir.GetPath())
srcLink, err := d.searchByPath(ctx, srcObj.GetPath(), srcObj.IsDir())
if err != nil {
return nil, fmt.Errorf("failed to find source: %w", err)
}
var dstParentLinkID string
if dstDir.GetPath() == "/" {
dstParentLinkID = d.RootLink.LinkID
} else {
dstLink, err := d.searchByPath(ctx, dstDir.GetPath(), true)
if err != nil {
return nil, fmt.Errorf("failed to find destination: %w", err)
}
dstParentLinkID = dstLink.LinkID
}
if srcObj.IsDir() {
// Check if destination is a descendant of source
if err := d.checkCircularMove(ctx, srcLink.LinkID, dstParentLinkID); err != nil {
return nil, err
}
}
// Encrypt the filename for the new location
encryptedName, err := d.encryptFileName(ctx, srcObj.GetName(), dstParentLinkID)
if err != nil {
return nil, fmt.Errorf("failed to encrypt filename: %w", err)
}
newHash, err := d.generateNameHash(ctx, srcObj.GetName(), dstParentLinkID)
if err != nil {
return nil, fmt.Errorf("failed to generate new hash: %w", err)
}
originalHash, err := d.getOriginalNameHash(srcLink)
if err != nil {
return nil, fmt.Errorf("failed to get original hash: %w", err)
}
// Re-encrypt node passphrase for new parent context
reencryptedPassphrase, err := d.reencryptNodePassphrase(ctx, srcLink, dstParentLinkID)
if err != nil {
return nil, fmt.Errorf("failed to re-encrypt node passphrase: %w", err)
}
moveReq := MoveRequest{
ParentLinkID: dstParentLinkID,
NodePassphrase: reencryptedPassphrase,
Name: encryptedName,
NameSignatureEmail: d.MainShare.Creator,
Hash: newHash,
OriginalHash: originalHash,
ContentHash: nil,
// *** Causes rejection ***
/* NodePassphraseSignature: srcLink.NodePassphraseSignature, */
}
//fmt.Printf("DEBUG MoveRequest validation:\n")
//fmt.Printf(" Name length: %d\n", len(moveReq.Name))
//fmt.Printf(" Hash: %s\n", moveReq.Hash)
//fmt.Printf(" OriginalHash: %s\n", moveReq.OriginalHash)
//fmt.Printf(" NodePassphrase length: %d\n", len(moveReq.NodePassphrase))
/* fmt.Printf(" NodePassphraseSignature length: %d\n", len(moveReq.NodePassphraseSignature)) */
//fmt.Printf(" NameSignatureEmail: %s\n", moveReq.NameSignatureEmail)
err = d.executeMoveAPI(ctx, srcLink.LinkID, moveReq)
if err != nil {
return nil, fmt.Errorf("move API call failed: %w", err)
}
return &model.Object{
Name: srcObj.GetName(),
Size: srcObj.GetSize(),
Modified: srcObj.ModTime(),
IsFolder: srcObj.IsDir(),
}, nil
}
func (d *ProtonDrive) reencryptNodePassphrase(ctx context.Context, srcLink *proton.Link, dstParentLinkID string) (string, error) {
// Get source parent link with metadata
srcParentLink, err := d.getLink(ctx, srcLink.ParentLinkID)
if err != nil {
return "", fmt.Errorf("failed to get source parent link: %w", err)
}
// Get source parent keyring using link object
srcParentKR, err := d.getLinkKR(ctx, srcParentLink)
if err != nil {
return "", fmt.Errorf("failed to get source parent keyring: %w", err)
}
// Get destination parent link with metadata
dstParentLink, err := d.getLink(ctx, dstParentLinkID)
if err != nil {
return "", fmt.Errorf("failed to get destination parent link: %w", err)
}
// Get destination parent keyring using link object
dstParentKR, err := d.getLinkKR(ctx, dstParentLink)
if err != nil {
return "", fmt.Errorf("failed to get destination parent keyring: %w", err)
}
// Re-encrypt the node passphrase from source parent context to destination parent context
reencryptedPassphrase, err := reencryptKeyPacket(srcParentKR, dstParentKR, d.DefaultAddrKR, srcLink.NodePassphrase)
if err != nil {
return "", fmt.Errorf("failed to re-encrypt key packet: %w", err)
}
return reencryptedPassphrase, nil
}
func (d *ProtonDrive) generateNameHash(ctx context.Context, name string, parentLinkID string) (string, error) {
parentLink, err := d.getLink(ctx, parentLinkID)
if err != nil {
return "", fmt.Errorf("failed to get parent link: %w", err)
}
// Get parent node keyring
parentNodeKR, err := d.getLinkKR(ctx, parentLink)
if err != nil {
return "", fmt.Errorf("failed to get parent keyring: %w", err)
}
// Get signature verification keyring
signatureVerificationKR, err := d.getSignatureVerificationKeyring([]string{parentLink.SignatureEmail}, parentNodeKR)
if err != nil {
return "", fmt.Errorf("failed to get signature verification keyring: %w", err)
}
parentHashKey, err := parentLink.GetHashKey(parentNodeKR, signatureVerificationKR)
if err != nil {
return "", fmt.Errorf("failed to get parent hash key: %w", err)
}
nameHash, err := proton.GetNameHash(name, parentHashKey)
if err != nil {
return "", fmt.Errorf("failed to generate name hash: %w", err)
}
return nameHash, nil
}
func reencryptKeyPacket(srcKR, dstKR, _ *crypto.KeyRing, passphrase string) (string, error) { // addrKR (3)
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 (d *ProtonDrive) checkCircularMove(ctx context.Context, srcLinkID, dstParentLinkID string) error {
currentLinkID := dstParentLinkID
for currentLinkID != "" && currentLinkID != d.RootLink.LinkID {
if currentLinkID == srcLinkID {
return fmt.Errorf("cannot move folder into itself or its subfolder")
}
currentLink, err := d.getLink(ctx, currentLinkID)
if err != nil {
return err
}
currentLinkID = currentLink.ParentLinkID
}
return nil
}