Initial commit

This commit is contained in:
Johan 2026-02-01 02:00:33 -05:00
commit 7b643bc6a5
7 changed files with 957 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@ -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

64
CLAUDE.md Normal file
View File

@ -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
```

65
README.md Normal file
View File

@ -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.

38
go.mod Normal file
View File

@ -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
)

175
go.sum Normal file
View File

@ -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=

584
main.go Normal file
View File

@ -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()
}

BIN
proton-backup Executable file

Binary file not shown.