commit 7b643bc6a5225c3721fe9cb9c93453e3b540ad3f Author: Johan Date: Sun Feb 1 02:00:33 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c64ecb --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ff6f91d --- /dev/null +++ b/CLAUDE.md @@ -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 +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ebfd26 --- /dev/null +++ b/README.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1bb1661 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2f5f48c --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..84d1fa3 --- /dev/null +++ b/main.go @@ -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 -remote ") + 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() +} diff --git a/proton-backup b/proton-backup new file mode 100755 index 0000000..33fb031 Binary files /dev/null and b/proton-backup differ