Initial commit
This commit is contained in:
commit
7b643bc6a5
|
|
@ -0,0 +1,31 @@
|
|||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/bin/
|
||||
/dist/
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Databases
|
||||
*.db
|
||||
*.sqlite
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# proton-backup
|
||||
|
||||
A Go CLI tool for backing up local directories to Proton Drive with rate limiting, retry logic, and resume support.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cp proton-backup ~/bin/
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
proton-backup -source /path/to/backup -remote /proton/drive/path
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `main.go` - Single-file implementation containing:
|
||||
- CLI argument parsing
|
||||
- Proton Drive authentication (username/password or env vars)
|
||||
- Credential caching (`~/.config/proton-backup/credentials.json`)
|
||||
- Directory walking and file upload
|
||||
- SQLite state tracking for resume (`~/.config/proton-backup/state.db`)
|
||||
- Rate limiting and exponential backoff on errors
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `github.com/henrybear327/Proton-API-Bridge` - Proton Drive API with client-side encryption
|
||||
- `github.com/mattn/go-sqlite3` - State tracking
|
||||
- `golang.org/x/term` - Secure password input
|
||||
|
||||
## Key Configuration
|
||||
|
||||
The `AppVersion` header is required by Proton's API. Current value: `web-drive@5.2.0` (from Proton WebClients repo). If authentication fails with "Invalid app version", check https://github.com/ProtonMail/WebClients/blob/main/applications/drive/package.json for the current version.
|
||||
|
||||
## Authentication
|
||||
|
||||
First run prompts for credentials interactively. Alternatively, set environment variables:
|
||||
- `PROTON_USERNAME`
|
||||
- `PROTON_PASSWORD`
|
||||
|
||||
## Systemd Integration
|
||||
|
||||
Timer and service files are in `~/.config/systemd/user/`:
|
||||
- `backup-immich-proton.service`
|
||||
- `backup-immich-proton.timer`
|
||||
|
||||
## Testing
|
||||
|
||||
No automated tests yet. Manual testing:
|
||||
```bash
|
||||
# Dry run (still requires auth)
|
||||
proton-backup -source /tank/immich/library -remote /backups/immich -dry-run
|
||||
|
||||
# Verbose output
|
||||
proton-backup -source /tank/immich/library -remote /backups/immich -verbose
|
||||
```
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# proton-backup
|
||||
|
||||
Backup tool for syncing local directories to Proton Drive with rate limiting and resume support.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go build
|
||||
cp proton-backup ~/bin/
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
proton-backup -source /path/to/backup -remote /proton/drive/path
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
- `-source` - Local directory to backup (required)
|
||||
- `-remote` - Remote path on Proton Drive (required)
|
||||
- `-exclude` - Comma-separated patterns to exclude (default: "thumbs,encoded-video")
|
||||
- `-rate-limit` - Milliseconds between uploads (default: 2000)
|
||||
- `-retries` - Number of retries on failure (default: 5)
|
||||
- `-retry-delay` - Milliseconds to wait before retry (default: 30000)
|
||||
- `-dry-run` - Show what would be uploaded without uploading
|
||||
- `-verbose` - Verbose output
|
||||
|
||||
## First Run
|
||||
|
||||
On first run, you'll be prompted for Proton credentials:
|
||||
|
||||
```bash
|
||||
proton-backup -source /tank/immich/library -remote /backups/immich
|
||||
```
|
||||
|
||||
Credentials are cached in `~/.config/proton-backup/credentials.json`
|
||||
|
||||
## Immich Backup
|
||||
|
||||
Configured to run daily at 04:00 via systemd timer.
|
||||
|
||||
```bash
|
||||
# Check timer status
|
||||
systemctl --user status backup-immich-proton.timer
|
||||
|
||||
# Start timer
|
||||
systemctl --user start backup-immich-proton.timer
|
||||
|
||||
# Run manually
|
||||
systemctl --user start backup-immich-proton.service
|
||||
|
||||
# View logs
|
||||
tail -f ~/logs/backup-immich-proton.log
|
||||
```
|
||||
|
||||
## State Tracking
|
||||
|
||||
Upload state is tracked in `~/.config/proton-backup/state.db` (SQLite).
|
||||
Files are identified by path + size + mtime. Only changed files are re-uploaded.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Default: 2 second delay between uploads to avoid Proton's anti-abuse limits.
|
||||
Exponential backoff on 422/429 errors.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
module github.com/johan/proton-backup
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/henrybear327/Proton-API-Bridge v1.0.0
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
golang.org/x/term v0.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
|
||||
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
|
||||
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
|
||||
github.com/ProtonMail/go-srp v0.0.7 // indirect
|
||||
github.com/ProtonMail/gopenpgp/v2 v2.7.3 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/bradenaw/juniper v0.13.1 // indirect
|
||||
github.com/cloudflare/circl v1.3.3 // indirect
|
||||
github.com/cronokirby/saferith v0.33.0 // indirect
|
||||
github.com/emersion/go-message v0.17.0 // indirect
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 // indirect
|
||||
github.com/go-resty/resty/v2 v2.7.0 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/henrybear327/go-proton-api v0.0.0-20230907193451-e563407504ce // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/relvacode/iso8601 v1.3.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.15.0 // indirect
|
||||
golang.org/x/sync v0.3.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
|
||||
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
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.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk=
|
||||
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/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.3 h1:AJu1OI/1UWVYZl6QcCLKGu9OTngS2r52618uGlje84I=
|
||||
github.com/ProtonMail/gopenpgp/v2 v2.7.3/go.mod h1:IhkNEDaxec6NyzSI0PlxapinnwPVIESk8/76da3Ct3g=
|
||||
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.1 h1:9P7/xeaYuEyqPuJHSHCJoisWyPvZH4FAi59BxJLh7F8=
|
||||
github.com/bradenaw/juniper v0.13.1/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/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
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.17.0 h1:NIdSKHiVUx4qKqdd0HyJFD41cW8iFguM2XJnRZWQH04=
|
||||
github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw=
|
||||
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-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/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-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
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/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/henrybear327/Proton-API-Bridge v1.0.0 h1:gjKAaWfKu++77WsZTHg6FUyPC5W0LTKWQciUm8PMZb0=
|
||||
github.com/henrybear327/Proton-API-Bridge v1.0.0/go.mod h1:gunH16hf6U74W2b9CGDaWRadiLICsoJ6KRkSt53zLts=
|
||||
github.com/henrybear327/go-proton-api v0.0.0-20230907193451-e563407504ce h1:n1URi7VYiwX/3akX51keQXi6Huy4lJdVc4biJHYk3iw=
|
||||
github.com/henrybear327/go-proton-api v0.0.0-20230907193451-e563407504ce/go.mod h1:w63MZuzufKcIZ93pwRgiOtxMXYafI8H74D77AxytOBc=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
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/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
|
||||
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
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.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
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.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
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.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.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=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -0,0 +1,584 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
proton "github.com/henrybear327/go-proton-api"
|
||||
proton_api_bridge "github.com/henrybear327/Proton-API-Bridge"
|
||||
"github.com/henrybear327/Proton-API-Bridge/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
credentialFile = "credentials.json"
|
||||
stateDBFile = "state.db"
|
||||
version = "0.1.0"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SourceDir string
|
||||
RemoteDir string
|
||||
ExcludePatterns []string
|
||||
DryRun bool
|
||||
Verbose bool
|
||||
RateLimitMs int
|
||||
RetryCount int
|
||||
RetryDelayMs int
|
||||
}
|
||||
|
||||
type BackupState struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse flags
|
||||
sourceDir := flag.String("source", "", "Source directory to backup")
|
||||
remoteDir := flag.String("remote", "", "Remote directory on Proton Drive (e.g., /backups/immich)")
|
||||
exclude := flag.String("exclude", "thumbs,encoded-video", "Comma-separated patterns to exclude")
|
||||
dryRun := flag.Bool("dry-run", false, "Show what would be uploaded without uploading")
|
||||
verbose := flag.Bool("verbose", false, "Verbose output")
|
||||
rateLimit := flag.Int("rate-limit", 2000, "Milliseconds between uploads")
|
||||
retryCount := flag.Int("retries", 5, "Number of retries on failure")
|
||||
retryDelay := flag.Int("retry-delay", 30000, "Milliseconds to wait before retry")
|
||||
showVersion := flag.Bool("version", false, "Show version")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("proton-backup %s\n", version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *sourceDir == "" || *remoteDir == "" {
|
||||
fmt.Println("Usage: proton-backup -source <dir> -remote <remote-path>")
|
||||
fmt.Println()
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Parse exclude patterns
|
||||
var excludePatterns []string
|
||||
if *exclude != "" {
|
||||
excludePatterns = strings.Split(*exclude, ",")
|
||||
}
|
||||
|
||||
config := Config{
|
||||
SourceDir: *sourceDir,
|
||||
RemoteDir: *remoteDir,
|
||||
ExcludePatterns: excludePatterns,
|
||||
DryRun: *dryRun,
|
||||
Verbose: *verbose,
|
||||
RateLimitMs: *rateLimit,
|
||||
RetryCount: *retryCount,
|
||||
RetryDelayMs: *retryDelay,
|
||||
}
|
||||
|
||||
if err := run(config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(config Config) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize state directory
|
||||
stateDir := filepath.Join(os.Getenv("HOME"), ".config", "proton-backup")
|
||||
if err := os.MkdirAll(stateDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create state directory: %w", err)
|
||||
}
|
||||
|
||||
// Initialize state database
|
||||
state, err := NewBackupState(filepath.Join(stateDir, stateDBFile))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize state: %w", err)
|
||||
}
|
||||
defer state.Close()
|
||||
|
||||
// Connect to Proton Drive
|
||||
credPath := filepath.Join(stateDir, credentialFile)
|
||||
protonDrive, err := connectProton(ctx, credPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to Proton Drive: %w", err)
|
||||
}
|
||||
defer protonDrive.Logout(ctx)
|
||||
|
||||
fmt.Println("Connected to Proton Drive")
|
||||
|
||||
// Get root folder
|
||||
rootLink := protonDrive.RootLink
|
||||
if rootLink == nil {
|
||||
return fmt.Errorf("failed to get root link")
|
||||
}
|
||||
|
||||
// Create or get remote directory
|
||||
remoteFolderID, err := ensureRemoteDir(ctx, protonDrive, config.RemoteDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create remote directory: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Remote folder: %s (ID: %s)\n", config.RemoteDir, remoteFolderID)
|
||||
|
||||
// Walk source directory and sync
|
||||
return syncDirectory(ctx, protonDrive, state, config, remoteFolderID)
|
||||
}
|
||||
|
||||
func connectProton(ctx context.Context, credPath string) (*proton_api_bridge.ProtonDrive, error) {
|
||||
cfg := proton_api_bridge.NewDefaultConfig()
|
||||
cfg.AppVersion = "web-drive@5.2.0"
|
||||
cfg.UserAgent = "proton-backup/0.1.0"
|
||||
cfg.ReplaceExistingDraft = true
|
||||
cfg.ConcurrentBlockUploadCount = 2
|
||||
// Reset credentials from default config
|
||||
cfg.UseReusableLogin = false
|
||||
cfg.ReusableCredential = &common.ReusableCredentialData{}
|
||||
cfg.FirstLoginCredential = nil
|
||||
|
||||
// Check for cached credentials
|
||||
if data, err := os.ReadFile(credPath); err == nil {
|
||||
var savedCreds common.ProtonDriveCredential
|
||||
if err := json.Unmarshal(data, &savedCreds); err == nil && savedCreds.UID != "" {
|
||||
cfg.UseReusableLogin = true
|
||||
cfg.ReusableCredential = &common.ReusableCredentialData{
|
||||
UID: savedCreds.UID,
|
||||
AccessToken: savedCreds.AccessToken,
|
||||
RefreshToken: savedCreds.RefreshToken,
|
||||
SaltedKeyPass: savedCreds.SaltedKeyPass,
|
||||
}
|
||||
fmt.Println("Using cached credentials...")
|
||||
}
|
||||
}
|
||||
|
||||
// If no cached credentials, prompt for login
|
||||
if !cfg.UseReusableLogin {
|
||||
fmt.Println("No cached credentials found, prompting for login...")
|
||||
username, password, err := promptCredentials()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read credentials: %w", err)
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
return nil, fmt.Errorf("username and password are required")
|
||||
}
|
||||
cfg.FirstLoginCredential = &common.FirstLoginCredentialData{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
fmt.Printf("Logging in as: %s\n", username)
|
||||
}
|
||||
|
||||
// Auth handler - called when auth is refreshed
|
||||
authHandler := func(auth proton.Auth) {
|
||||
fmt.Println("Auth refreshed, will save on exit")
|
||||
}
|
||||
|
||||
// Deauth handler
|
||||
deauthHandler := func() {
|
||||
fmt.Println("Deauthenticated, removing cached credentials")
|
||||
os.Remove(credPath)
|
||||
}
|
||||
|
||||
protonDrive, newCreds, err := proton_api_bridge.NewProtonDrive(ctx, cfg, authHandler, deauthHandler)
|
||||
if err != nil {
|
||||
// If cached credentials failed, try fresh login
|
||||
if cfg.UseReusableLogin {
|
||||
fmt.Println("Cached credentials failed, please login again")
|
||||
os.Remove(credPath)
|
||||
|
||||
username, password, err := promptCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.UseReusableLogin = false
|
||||
cfg.ReusableCredential = nil
|
||||
cfg.FirstLoginCredential = &common.FirstLoginCredentialData{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
protonDrive, newCreds, err = proton_api_bridge.NewProtonDrive(ctx, cfg, authHandler, deauthHandler)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save credentials for next time
|
||||
if newCreds != nil {
|
||||
data, _ := json.MarshalIndent(newCreds, "", " ")
|
||||
if err := os.WriteFile(credPath, data, 0600); err != nil {
|
||||
fmt.Printf("Warning: failed to save credentials: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Credentials saved for next run")
|
||||
}
|
||||
}
|
||||
|
||||
return protonDrive, nil
|
||||
}
|
||||
|
||||
func promptCredentials() (string, string, error) {
|
||||
// Check environment variables first (for non-interactive use)
|
||||
username := os.Getenv("PROTON_USERNAME")
|
||||
password := os.Getenv("PROTON_PASSWORD")
|
||||
if username != "" && password != "" {
|
||||
fmt.Println("Using credentials from environment variables")
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Print("Proton username: ")
|
||||
usernameInput, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
username = strings.TrimSpace(usernameInput)
|
||||
|
||||
fmt.Print("Proton password: ")
|
||||
// Check if stdin is a terminal
|
||||
if term.IsTerminal(int(syscall.Stdin)) {
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
password = string(passwordBytes)
|
||||
} else {
|
||||
// Non-terminal: read password from stdin (for piped input)
|
||||
passwordInput, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
password = strings.TrimSpace(passwordInput)
|
||||
}
|
||||
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func ensureRemoteDir(ctx context.Context, pd *proton_api_bridge.ProtonDrive, remotePath string) (string, error) {
|
||||
parts := strings.Split(strings.Trim(remotePath, "/"), "/")
|
||||
|
||||
currentID := pd.RootLink.LinkID
|
||||
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to find existing folder (searchForFile=false, searchForFolder=true, state=1 for active)
|
||||
link, err := pd.SearchByNameInActiveFolderByID(ctx, currentID, part, false, true, 1)
|
||||
if err == nil && link != nil {
|
||||
currentID = link.LinkID
|
||||
continue
|
||||
}
|
||||
|
||||
// Create folder
|
||||
newID, err := pd.CreateNewFolderByID(ctx, currentID, part)
|
||||
if err != nil {
|
||||
// Maybe it was created by another process, try to find it again
|
||||
link, err2 := pd.SearchByNameInActiveFolderByID(ctx, currentID, part, false, true, 1)
|
||||
if err2 == nil && link != nil {
|
||||
currentID = link.LinkID
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("failed to create folder %s: %w", part, err)
|
||||
}
|
||||
currentID = newID
|
||||
fmt.Printf("Created remote folder: %s\n", part)
|
||||
}
|
||||
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func syncDirectory(ctx context.Context, pd *proton_api_bridge.ProtonDrive, state *BackupState, config Config, remoteFolderID string) error {
|
||||
var totalFiles, uploadedFiles, skippedFiles, errorFiles int
|
||||
startTime := time.Now()
|
||||
|
||||
// First pass: count files
|
||||
fmt.Println("Scanning source directory...")
|
||||
err := filepath.Walk(config.SourceDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip errors
|
||||
}
|
||||
if !info.IsDir() && !shouldExclude(path, config.ExcludePatterns) {
|
||||
totalFiles++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Found %d files to process\n", totalFiles)
|
||||
|
||||
// Create a folder cache to avoid repeated API calls
|
||||
folderCache := make(map[string]string)
|
||||
folderCache[""] = remoteFolderID
|
||||
folderCache["."] = remoteFolderID
|
||||
|
||||
// Second pass: upload files
|
||||
processed := 0
|
||||
err = filepath.Walk(config.SourceDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check exclusions
|
||||
if shouldExclude(path, config.ExcludePatterns) {
|
||||
if config.Verbose {
|
||||
fmt.Printf("SKIP (excluded): %s\n", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
processed++
|
||||
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(config.SourceDir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if already uploaded
|
||||
fileHash := hashFileInfo(path, info)
|
||||
if state.IsUploaded(relPath, fileHash) {
|
||||
skippedFiles++
|
||||
if config.Verbose {
|
||||
fmt.Printf("SKIP (exists) [%d/%d]: %s\n", processed, totalFiles, relPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure parent folder exists on remote
|
||||
parentRelPath := filepath.Dir(relPath)
|
||||
parentID, err := ensureRemotePath(ctx, pd, folderCache, remoteFolderID, parentRelPath)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR (folder) [%d/%d]: %s - %v\n", processed, totalFiles, relPath, err)
|
||||
errorFiles++
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload file
|
||||
if config.DryRun {
|
||||
fmt.Printf("DRY-RUN [%d/%d]: would upload %s\n", processed, totalFiles, relPath)
|
||||
uploadedFiles++
|
||||
} else {
|
||||
err = uploadWithRetry(ctx, pd, path, parentID, info, config)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR [%d/%d]: %s - %v\n", processed, totalFiles, relPath, err)
|
||||
errorFiles++
|
||||
} else {
|
||||
uploadedFiles++
|
||||
state.MarkUploaded(relPath, fileHash)
|
||||
fmt.Printf("OK [%d/%d]: %s (%s)\n", processed, totalFiles, relPath, humanSize(info.Size()))
|
||||
}
|
||||
|
||||
// Rate limiting between uploads
|
||||
time.Sleep(time.Duration(config.RateLimitMs) * time.Millisecond)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
fmt.Printf("\n=== Summary ===\n")
|
||||
fmt.Printf("Duration: %s\n", elapsed.Round(time.Second))
|
||||
fmt.Printf("Total files: %d\n", totalFiles)
|
||||
fmt.Printf("Uploaded: %d\n", uploadedFiles)
|
||||
fmt.Printf("Skipped (already uploaded): %d\n", skippedFiles)
|
||||
fmt.Printf("Errors: %d\n", errorFiles)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureRemotePath(ctx context.Context, pd *proton_api_bridge.ProtonDrive, cache map[string]string, rootID, relPath string) (string, error) {
|
||||
if relPath == "." || relPath == "" {
|
||||
return rootID, nil
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if id, ok := cache[relPath]; ok {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Ensure parent exists first
|
||||
parentPath := filepath.Dir(relPath)
|
||||
parentID, err := ensureRemotePath(ctx, pd, cache, rootID, parentPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
folderName := filepath.Base(relPath)
|
||||
|
||||
// Try to find existing folder
|
||||
link, err := pd.SearchByNameInActiveFolderByID(ctx, parentID, folderName, false, true, 1)
|
||||
if err == nil && link != nil {
|
||||
cache[relPath] = link.LinkID
|
||||
return link.LinkID, nil
|
||||
}
|
||||
|
||||
// Create folder
|
||||
newID, err := pd.CreateNewFolderByID(ctx, parentID, folderName)
|
||||
if err != nil {
|
||||
// Maybe created concurrently, try to find again
|
||||
link, err2 := pd.SearchByNameInActiveFolderByID(ctx, parentID, folderName, false, true, 1)
|
||||
if err2 == nil && link != nil {
|
||||
cache[relPath] = link.LinkID
|
||||
return link.LinkID, nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to create folder %s: %w", folderName, err)
|
||||
}
|
||||
|
||||
cache[relPath] = newID
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
func uploadWithRetry(ctx context.Context, pd *proton_api_bridge.ProtonDrive, localPath, parentID string, info os.FileInfo, config Config) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= config.RetryCount; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := time.Duration(config.RetryDelayMs) * time.Millisecond
|
||||
// Exponential backoff for rate limit errors
|
||||
if lastErr != nil {
|
||||
errStr := lastErr.Error()
|
||||
if strings.Contains(errStr, "422") || strings.Contains(errStr, "429") {
|
||||
delay = delay * time.Duration(attempt+1)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" Retry %d/%d after %v...\n", attempt, config.RetryCount, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
// Open file for reading
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
|
||||
// Upload using reader API (takes parentLinkID string)
|
||||
_, _, err = pd.UploadFileByReader(ctx, parentID, info.Name(), info.ModTime(), file, 0)
|
||||
file.Close()
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if it's a retryable error
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "422") || strings.Contains(errStr, "429") ||
|
||||
strings.Contains(errStr, "retry") || strings.Contains(errStr, "500") ||
|
||||
strings.Contains(errStr, "503") {
|
||||
continue
|
||||
}
|
||||
|
||||
// For non-retryable errors, check if file already exists
|
||||
if strings.Contains(errStr, "already exists") || strings.Contains(errStr, "Draft already exists") {
|
||||
// File exists, treat as success
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func shouldExclude(path string, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
// Exclude if pattern appears as a path component
|
||||
if strings.Contains(path, string(os.PathSeparator)+pattern+string(os.PathSeparator)) ||
|
||||
strings.HasSuffix(path, string(os.PathSeparator)+pattern) {
|
||||
return true
|
||||
}
|
||||
// Also exclude hidden files
|
||||
if strings.Contains(filepath.Base(path), "/.") || strings.HasPrefix(filepath.Base(path), ".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hashFileInfo(path string, info os.FileInfo) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(path))
|
||||
h.Write([]byte(fmt.Sprintf("%d", info.Size())))
|
||||
h.Write([]byte(fmt.Sprintf("%d", info.ModTime().Unix())))
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
func humanSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// BackupState tracks what has been uploaded
|
||||
func NewBackupState(dbPath string) (*BackupState, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS uploaded_files (
|
||||
path TEXT PRIMARY KEY,
|
||||
hash TEXT,
|
||||
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &BackupState{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *BackupState) IsUploaded(path, hash string) bool {
|
||||
var storedHash string
|
||||
err := s.db.QueryRow("SELECT hash FROM uploaded_files WHERE path = ?", path).Scan(&storedHash)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return storedHash == hash
|
||||
}
|
||||
|
||||
func (s *BackupState) MarkUploaded(path, hash string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT OR REPLACE INTO uploaded_files (path, hash, uploaded_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
path, hash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *BackupState) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in New Issue