OAuth Google Drive: use a plain folder path instead of a folder ID

A real OAuth-authorized account has its own "My Drive" with actual
storage, so unlike the service-account path (which has none of its own
and can only write into an explicitly shared-by-ID folder), there's no
need to hunt down a numeric folder ID — a plain path segment works and
rclone creates it automatically on first upload if it doesn't exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-09-05 23:26:38 +03:00
parent 0cea565fd5
commit e773b19508
5 changed files with 44 additions and 17 deletions

View file

@ -112,6 +112,7 @@ def backups_list():
"backup_gdrive_client_secret",
"backup_gdrive_scope",
"backup_gdrive_token",
"backup_gdrive_folder_path",
"backup_s3_provider",
"backup_s3_access_key_id",
"backup_s3_secret_access_key",
@ -146,6 +147,7 @@ def backups_remote_settings():
Setting.set("backup_gdrive_client_secret", request.form.get("backup_gdrive_client_secret", "").strip())
Setting.set("backup_gdrive_scope", request.form.get("backup_gdrive_scope", "").strip())
Setting.set("backup_gdrive_token", request.form.get("backup_gdrive_token", "").strip())
Setting.set("backup_gdrive_folder_path", request.form.get("backup_gdrive_folder_path", "").strip())
Setting.set("backup_s3_provider", request.form.get("backup_s3_provider", "AWS").strip() or "AWS")
Setting.set("backup_s3_access_key_id", request.form.get("backup_s3_access_key_id", "").strip())
Setting.set("backup_s3_secret_access_key", request.form.get("backup_s3_secret_access_key", "").strip())

View file

@ -639,6 +639,7 @@ DEFAULT_SETTINGS = {
"backup_gdrive_client_secret": "",
"backup_gdrive_scope": "",
"backup_gdrive_token": "",
"backup_gdrive_folder_path": "",
"backup_s3_provider": "AWS",
"backup_s3_access_key_id": "",
"backup_s3_secret_access_key": "",

View file

@ -78,14 +78,14 @@
</div>
<div id="fields-gdrive" class="backup-remote-fields">
<label>ID папки Google Drive <span class="muted">(спільне поле для обох способів нижче — обов'язково для сервісного акаунта, необов'язково для OAuth: порожнє значення означає весь «Мій диск»)</span></label>
<input type="text" name="backup_gdrive_folder_id" value="{{ remote_values.backup_gdrive_folder_id }}" placeholder="1AbCдEfGhIjKlMnOpQrStUvWxYz">
<p class="muted field-hint">Заповніть <b>або</b> «Сервісний акаунт», <b>або</b> «OAuth» нижче — не обидва одразу.</p>
<p class="muted field-hint" style="margin-top:0;">Заповніть <b>або</b> «Сервісний акаунт», <b>або</b> «OAuth» нижче — не обидва одразу.</p>
<label>Сервісний акаунт: ключ Google (JSON)</label>
<textarea name="backup_gdrive_service_account_json" rows="4" placeholder='{"type": "service_account", "client_email": "...", ...}'>{{ remote_values.backup_gdrive_service_account_json }}</textarea>
<label>ID папки Google Drive <span class="muted">(обов'язково для сервісного акаунта — у нього немає власного диска, тож без явно розшареної папки йому нема куди писати)</span></label>
<input type="text" name="backup_gdrive_folder_id" value="{{ remote_values.backup_gdrive_folder_id }}" placeholder="1AbCдEfGhIjKlMnOpQrStUvWxYz">
<details style="margin-top:12px;">
<summary style="cursor:pointer;font-weight:700;font-size:13px;">Сервісний акаунт — як налаштувати (крок за кроком)</summary>
<ol class="muted" style="font-size:12.5px;line-height:1.8;padding-left:20px;margin-top:10px;">
@ -116,13 +116,16 @@
<label>token</label>
<textarea name="backup_gdrive_token" rows="3" placeholder='{"access_token": "...", "refresh_token": "...", ...}'>{{ remote_values.backup_gdrive_token }}</textarea>
<label>Шлях до папки <span class="muted">(необов'язково — просто назва, без ID; порожньо = корінь «Мій диск». Якщо такої папки ще немає, rclone створить її сам)</span></label>
<input type="text" name="backup_gdrive_folder_path" value="{{ remote_values.backup_gdrive_folder_path }}" placeholder="напр. Бекапи/BoberBBQ">
<details style="margin-top:12px;">
<summary style="cursor:pointer;font-weight:700;font-size:13px;">OAuth — як налаштувати (крок за кроком)</summary>
<ol class="muted" style="font-size:12.5px;line-height:1.8;padding-left:20px;margin-top:10px;">
<li>Встановіть rclone на своєму комп'ютері (не на сервері) — <a href="https://rclone.org/downloads/" target="_blank">rclone.org/downloads</a>.</li>
<li>Запустіть <code>rclone config</code>, оберіть «New remote» → тип <b>drive</b>, погодьтесь на автентифікацію через браузер (увійдіть у свій Google-акаунт).</li>
<li>Коли готово, відкрийте створений конфіг (шлях покаже сама команда, зазвичай <code>~/.config/rclone/rclone.conf</code>) і скопіюйте значення <code>client_id</code>, <code>client_secret</code>, <code>scope</code>, <code>token</code> у поля вище.</li>
<li>ID папки — необов'язково для OAuth (за замовчуванням доступний весь «Мій диск» акаунта, з яким ви увійшли); заповніть, якщо хочете обмежити конкретною папкою.</li>
<li>«Шлях до папки» — звичайна назва (не ID); можна лишити порожнім, і бекапи ляжуть у корінь «Мій диск» того акаунта, з яким ви увійшли.</li>
<li>Збережіть, натисніть «Перевірити підключення».</li>
</ol>
</details>

View file

@ -77,7 +77,6 @@ def _remote_env_and_path() -> tuple[dict, str]:
service_account_json = Setting.get("backup_gdrive_service_account_json", "").strip()
client_id = Setting.get("backup_gdrive_client_id", "").strip()
token = Setting.get("backup_gdrive_token", "").strip()
folder_id = Setting.get("backup_gdrive_folder_id", "").strip()
env[prefix + "TYPE"] = "drive"
if service_account_json:
@ -85,14 +84,19 @@ def _remote_env_and_path() -> tuple[dict, str]:
json.loads(service_account_json)
except json.JSONDecodeError as e:
raise BackupError(f"Невалідний JSON ключа сервісного акаунта: {e}") from e
env[prefix + "SERVICE_ACCOUNT_CREDENTIALS"] = service_account_json
folder_id = Setting.get("backup_gdrive_folder_id", "").strip()
if not folder_id:
# A service account has no Drive storage of its own — it
# can only write into a folder that's been explicitly
# shared with it, so this isn't optional the way it is for
# a real user's OAuth-authorized "My Drive" below.
# shared with it (root_folder_id is a backend option, not
# part of the path, so rclone can't just "create" this the
# way it can for a real OAuth-authorized account below).
raise BackupError("Для сервісного акаунта обов'язково вкажіть ID папки Google Drive")
elif client_id and token:
env[prefix + "SERVICE_ACCOUNT_CREDENTIALS"] = service_account_json
env[prefix + "ROOT_FOLDER_ID"] = folder_id
return env, f"{_REMOTE}:"
if client_id and token:
try:
json.loads(token)
except json.JSONDecodeError as e:
@ -105,13 +109,16 @@ def _remote_env_and_path() -> tuple[dict, str]:
if scope:
env[prefix + "SCOPE"] = scope
env[prefix + "TOKEN"] = token
else:
raise BackupError(
"Вкажіть або ключ сервісного акаунта, або client_id + token (OAuth) для Google Drive"
)
if folder_id:
env[prefix + "ROOT_FOLDER_ID"] = folder_id
return env, f"{_REMOTE}:"
# A real OAuth-authorized account has its own "My Drive" with
# real storage, so — unlike the service-account path above —
# there's no numeric folder id to hunt down: a plain path
# segment is enough, and rclone creates it on first upload if
# it doesn't exist yet.
folder_path = Setting.get("backup_gdrive_folder_path", "").strip().strip("/")
remote_path = f"{_REMOTE}:{folder_path}" if folder_path else f"{_REMOTE}:"
return env, remote_path
raise BackupError("Вкажіть або ключ сервісного акаунта, або client_id + token (OAuth) для Google Drive")
if remote_type == "s3":
access_key_id = Setting.get("backup_s3_access_key_id", "").strip()

View file

@ -77,6 +77,20 @@ def test_gdrive_oauth_env_and_path(app):
assert path == "backup:"
def test_gdrive_oauth_folder_path_is_a_plain_path_not_an_id(app):
with app.app_context():
Setting.set("backup_remote_type", "gdrive")
Setting.set("backup_gdrive_service_account_json", "")
Setting.set("backup_gdrive_client_id", "CLIENT123")
Setting.set("backup_gdrive_token", '{"access_token": "a"}')
Setting.set("backup_gdrive_folder_path", "/Бекапи/BoberBBQ/")
db.session.commit()
env, path = backup_remote._remote_env_and_path()
assert "RCLONE_CONFIG_BACKUP_ROOT_FOLDER_ID" not in env
assert path == "backup:Бекапи/BoberBBQ"
def test_gdrive_neither_method_configured_raises(app):
with app.app_context():
Setting.set("backup_remote_type", "gdrive")