From fd5010fe095cbe05b6eeb43c9dd570e244f166dd Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Tue, 28 Jul 2026 22:12:54 +0700 Subject: [PATCH 1/5] feat: add shared OAuth client behind access key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The author maintains a shared Google Cloud client as an alternative to setting up your own project. It is gated behind an access key, which is the client secret itself: the repository ships only the client id and a sha256 of the key. Google's token endpoint rejects this client without a secret, so the gate is real rather than decorative — the shared client cannot be used without the key, no matter what the sources reveal. The hash only allows failing fast instead of sending the user through the browser with a bad key. The first-run wizard now always sets up the user's own Cloud project; the shared client is reachable only via `goodoc login --key`. --- src/goodoc/auth.py | 54 ++++++++++++++++++++++++++++++++------------ src/goodoc/client.py | 14 ++++++++++++ src/goodoc/setup.py | 50 ++++++++++++++++------------------------ tests/conftest.py | 2 +- tests/test_auth.py | 47 ++++++++++++++++++++++++++++++++------ 5 files changed, 114 insertions(+), 53 deletions(-) create mode 100644 src/goodoc/client.py diff --git a/src/goodoc/auth.py b/src/goodoc/auth.py index 07a61b7..ed949f3 100644 --- a/src/goodoc/auth.py +++ b/src/goodoc/auth.py @@ -1,30 +1,54 @@ +import hashlib + from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow +from goodoc.client import ACCESS_KEY_HASH from goodoc.config import Config -from goodoc.setup import first_run_wizard +from goodoc.setup import authorize_shared, first_run_wizard -def get_credentials(config: Config) -> Credentials: - if not config.credentials_path.exists(): - return first_run_wizard(config) +class Auth: + @staticmethod + def get_credentials(config: Config) -> Credentials: + creds = None + + if config.token_path.exists(): + creds = Credentials.from_authorized_user_file(str(config.token_path), config.scopes) + + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + creds = Auth._authorize(config) + + Auth._save(config, creds) + + return creds - config.token_path.parent.mkdir(parents=True, exist_ok=True) + @staticmethod + def login_shared(config: Config, access_key: str) -> Credentials: + if hashlib.sha256(access_key.encode()).hexdigest() != ACCESS_KEY_HASH: + raise ValueError("Invalid access key.") - creds = None + creds = authorize_shared(config, access_key) + Auth._save(config, creds) - if config.token_path.exists(): - creds = Credentials.from_authorized_user_file(str(config.token_path), config.scopes) + return creds - if not creds or not creds.valid: - if creds and creds.expired and creds.refresh_token: - creds.refresh(Request()) - else: + @staticmethod + def _authorize(config: Config) -> Credentials: + if config.credentials_path.exists(): flow = InstalledAppFlow.from_client_secrets_file(str(config.credentials_path), config.scopes) - creds = flow.run_local_server(port=0) + + return flow.run_local_server(port=0) + + return first_run_wizard(config) + + @staticmethod + def _save(config: Config, creds: Credentials) -> None: + config.token_path.parent.mkdir(parents=True, exist_ok=True) with config.token_path.open("w") as f: f.write(creds.to_json()) - - return creds diff --git a/src/goodoc/client.py b/src/goodoc/client.py new file mode 100644 index 0000000..0bea52c --- /dev/null +++ b/src/goodoc/client.py @@ -0,0 +1,14 @@ +CLIENT_ID = "107871228272-0ik0igaudebbqco44gatgfrok469jhiq.apps.googleusercontent.com" + +ACCESS_KEY_HASH = "b5fe8c482ec4140dcd9a1d181e0400dbf798e41b5c369c3586cc8acbfe923d0c" + + +def client_config(access_key: str) -> dict[str, dict[str, str]]: + return { + "installed": { + "client_id": CLIENT_ID, + "client_secret": access_key, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + } + } diff --git a/src/goodoc/setup.py b/src/goodoc/setup.py index 463a327..c4b8468 100644 --- a/src/goodoc/setup.py +++ b/src/goodoc/setup.py @@ -7,11 +7,18 @@ from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow +from goodoc.client import client_config from goodoc.config import Config -def acquire_credentials(config: Config) -> None: - typer.echo("Step 1/2: Google Cloud credentials") +def authorize_shared(config: Config, access_key: str) -> Credentials: + flow = InstalledAppFlow.from_client_config(client_config(access_key), config.scopes) + + return flow.run_local_server(port=0) + + +def _acquire_credentials(config: Config) -> None: + typer.echo("Set up your own Google Cloud project:") typer.echo() typer.echo(" 1. Open Google Cloud Console → APIs & Services → Credentials") typer.echo(" 2. Create a project (or select existing)") @@ -30,10 +37,7 @@ def acquire_credentials(config: Config) -> None: downloads = Path.home() / "Downloads" suggestions = sorted(downloads.glob("client_secret_*.json"), key=lambda p: p.stat().st_mtime, reverse=True) - if suggestions: - hint = f" [{suggestions[0]}]" - else: - hint = "" + hint = f" [{suggestions[0]}]" if suggestions else "" raw = typer.prompt(f"Path to downloaded credentials JSON{hint}").strip() @@ -56,39 +60,25 @@ def acquire_credentials(config: Config) -> None: typer.echo(f"Saved to {config.credentials_path}") -def authorize(config: Config) -> Credentials: - typer.echo() - typer.echo("Step 2/2: Authorize goodoc with Google") - typer.echo("A browser window will open — sign in and allow access.") - typer.echo() - - flow = InstalledAppFlow.from_client_secrets_file(str(config.credentials_path), config.scopes) - creds = flow.run_local_server(port=0) - - config.token_path.parent.mkdir(parents=True, exist_ok=True) - - with config.token_path.open("w") as f: - f.write(creds.to_json()) - - typer.echo(f"Token saved to {config.token_path}") - - return creds - - def first_run_wizard(config: Config) -> Credentials: if not sys.stdin.isatty(): typer.echo("goodoc is not configured. Run 'goodoc ' from Terminal first.", err=True) raise typer.Exit(1) - typer.echo("First run — let's set up goodoc.") + typer.echo("First run — goodoc needs a Google Cloud project of its own.") + typer.echo("Takes a few minutes, once.") + typer.echo() + typer.echo("Got an access key from the author? Cancel and run instead:") + typer.echo(" goodoc login --key ") typer.echo() - acquire_credentials(config) - creds = authorize(config) + _acquire_credentials(config) typer.echo() - typer.echo("All done. Run 'goodoc ' to upload.") + typer.echo("A browser window will open — sign in and allow access.") typer.echo() - return creds + flow = InstalledAppFlow.from_client_secrets_file(str(config.credentials_path), config.scopes) + + return flow.run_local_server(port=0) diff --git a/tests/conftest.py b/tests/conftest.py index b633541..9ccae9f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,7 @@ def mock_creds(): @pytest.fixture def mock_get_credentials(monkeypatch): mock = MagicMock() - monkeypatch.setattr("goodoc.main.get_credentials", mock) + monkeypatch.setattr("goodoc.main.Auth.get_credentials", mock) return mock diff --git a/tests/test_auth.py b/tests/test_auth.py index 053e229..b5f2e64 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import MagicMock, patch -from goodoc.auth import get_credentials +from goodoc.auth import Auth from goodoc.config import Config @@ -12,11 +12,14 @@ def config(tmp_path): class TestGetCredentials: def test_no_credentials_runs_wizard(self, config): - with patch("goodoc.auth.first_run_wizard", return_value="wizard-creds") as wizard: - result = get_credentials(config) + wizard_creds = MagicMock() + wizard_creds.to_json.return_value = "{}" + + with patch("goodoc.auth.first_run_wizard", return_value=wizard_creds) as wizard: + result = Auth.get_credentials(config) wizard.assert_called_once_with(config) - assert result == "wizard-creds" + assert result is wizard_creds def test_valid_token_loaded_from_file(self, config): config.credentials_path.write_text("{}") @@ -26,7 +29,7 @@ def test_valid_token_loaded_from_file(self, config): with patch("goodoc.auth.Credentials.from_authorized_user_file", return_value=creds): with patch("goodoc.auth.InstalledAppFlow.from_client_secrets_file") as flow_factory: - result = get_credentials(config) + result = Auth.get_credentials(config) assert result is creds creds.refresh.assert_not_called() @@ -44,7 +47,7 @@ def test_expired_token_refreshed_and_written(self, config): with patch("goodoc.auth.Credentials.from_authorized_user_file", return_value=creds): with patch("goodoc.auth.Request"): - result = get_credentials(config) + result = Auth.get_credentials(config) creds.refresh.assert_called_once() assert result is creds @@ -62,7 +65,37 @@ def test_no_token_runs_flow_and_writes(self, config): flow.run_local_server.return_value = new_creds with patch("goodoc.auth.InstalledAppFlow.from_client_secrets_file", return_value=flow): - result = get_credentials(config) + result = Auth.get_credentials(config) assert result is new_creds assert config.token_path.read_text() == fresh_token + + +class TestLoginShared: + def test_invalid_key_rejected(self, config): + with patch("goodoc.auth.authorize_shared") as shared: + with pytest.raises(ValueError): + Auth.login_shared(config, "wrong-key") + + shared.assert_not_called() + assert not config.token_path.exists() + + def test_valid_key_authorizes_and_writes(self, config): + shared_token = '{"token": "shared"}' + + creds = MagicMock() + creds.to_json.return_value = shared_token + + with patch("goodoc.auth.authorize_shared", return_value=creds) as shared: + with patch("goodoc.auth.ACCESS_KEY_HASH", _sha256("right-key")): + result = Auth.login_shared(config, "right-key") + + shared.assert_called_once_with(config, "right-key") + assert result is creds + assert config.token_path.read_text() == shared_token + + +def _sha256(value: str) -> str: + import hashlib + + return hashlib.sha256(value.encode()).hexdigest() From c85cfb9399113b6914017eeab9a465c0e3e84625 Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Tue, 28 Jul 2026 22:13:04 +0700 Subject: [PATCH 2/5] feat: add login and logout commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `login` authorizes without uploading anything and accepts `--key` for the shared client; `logout` drops the stored token. Adding commands turns the app into a Click group, where a variadic argument on the callback swallows the command name — `goodoc login` would be read as a file path. `upload` is therefore a real command, and DefaultCommandGroup inserts it when the first argument is neither a known command nor an option. This keeps `goodoc file.docx` working, so the Automator Quick Action needs no change. --- src/goodoc/main.py | 55 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/goodoc/main.py b/src/goodoc/main.py index cdaed4e..0bc2916 100644 --- a/src/goodoc/main.py +++ b/src/goodoc/main.py @@ -1,13 +1,26 @@ import webbrowser from pathlib import Path +from typing import Any, Optional import typer +from typer.core import TyperGroup -from goodoc.auth import get_credentials +from goodoc.auth import Auth from goodoc.config import Config from goodoc.drive import MIME_MAP, upload -app = typer.Typer() +DEFAULT_COMMAND = "upload" + + +class DefaultCommandGroup(TyperGroup): + def parse_args(self, ctx: Any, args: list[str]) -> list[str]: + if args and args[0] not in self.commands and not args[0].startswith("-"): + args = [DEFAULT_COMMAND, *args] + + return super().parse_args(ctx, args) + + +app = typer.Typer(cls=DefaultCommandGroup, no_args_is_help=True) def validate_file(file: Path) -> str | None: @@ -21,8 +34,8 @@ def validate_file(file: Path) -> str | None: return None -@app.command() -def main( +@app.command(DEFAULT_COMMAND) +def upload_files( files: list[Path] = typer.Argument(..., help=f"Paths to files ({' / '.join(MIME_MAP)})"), no_open: bool = typer.Option(False, "--no-open", help="Do not open in browser"), ) -> None: @@ -35,7 +48,7 @@ def main( raise typer.Exit(1) - creds = get_credentials(config) + creds = Auth.get_credentials(config) for file in files: typer.echo(f"Uploading {file.name}...") @@ -47,5 +60,37 @@ def main( webbrowser.open(url) +@app.command() +def login( + key: Optional[str] = typer.Option(None, "--key", help="Access key for the author's shared client"), +) -> None: + """Authenticate with Google (without uploading a file).""" + config = Config.default() + + if key is None: + Auth.get_credentials(config) + else: + try: + Auth.login_shared(config, key) + except ValueError as error: + typer.echo(str(error), err=True) + + raise typer.Exit(1) + + typer.echo("Logged in.") + + +@app.command() +def logout() -> None: + """Remove stored token (re-authentication will be required on next run).""" + config = Config.default() + + if config.token_path.exists(): + config.token_path.unlink() + typer.echo("Logged out.") + else: + typer.echo("Not logged in.") + + if __name__ == "__main__": app() From 4b879bbe6f2e5a3a6f03e17c3a0521a967e23b1c Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Tue, 28 Jul 2026 22:13:11 +0700 Subject: [PATCH 3/5] docs: document commands, shared client and its limits The shared client runs in production without verification: no whitelist, but a permanent cap of 100 authorizations that only verification lifts, and verification needs a domain. --- CLAUDE.md | 30 +++++++++++++++++++++++------- README.md | 19 +++++++++++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a354b2..c0d5429 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,10 +51,12 @@ goodoc/ └── src/ └── goodoc/ ├── __init__.py - ├── main.py # CLI + Config - ├── auth.py # OAuth + ├── main.py # CLI: upload (по умолчанию) / login / logout + ├── config.py # пути и scopes + ├── auth.py # Auth — получение и обновление токена + ├── client.py # встроенный OAuth-клиент автора + хеш ключа доступа ├── drive.py # загрузка в Drive - └── setup.py # визард первого запуска (только credentials) + └── setup.py # визард первого запуска (свой Cloud-проект) ``` --- @@ -68,19 +70,33 @@ pipx install ./goodoc ## Использование ```bash -goodoc file.docx # загрузить и открыть в браузере +goodoc file.docx # загрузить и открыть в браузере goodoc file.xlsx --no-open # загрузить без открытия +goodoc login # авторизоваться без загрузки +goodoc logout # удалить токен ``` +`upload` — команда по умолчанию: `DefaultCommandGroup` в `main.py` подставляет её, если первый аргумент не имя команды. Без этого variadic-аргумент съедает `login`/`logout`. + --- ## OAuth -Credentials: `~/.config/goodoc/credentials.json` (Desktop app, из Google Cloud Console). -Токен: `~/.config/goodoc/token.json` — создаётся при первом запуске. +Два источника OAuth-клиента: + +| Путь | Когда | Клиент | +|---|---|---| +| Свой проект (по умолчанию) | визард первого запуска | `~/.config/goodoc/credentials.json` | +| Общий клиент автора | `goodoc login --key ` | `client.py`, проект «gooodoc» | + +Токен в обоих случаях: `~/.config/goodoc/token.json`. Scope: `https://www.googleapis.com/auth/drive.file` — доступ только к файлам созданным этим приложением. -При первом запуске откроется браузер для авторизации. Последующие запуски — молча. +Общий клиент: Production без верификации — белого списка нет, но user cap **100 авторизаций на весь срок проекта**, необратимо. Снять потолок можно только верификацией, а она требует домена. + +**Ключ доступа = `client_secret` общего клиента.** В репозитории лежит только `CLIENT_ID` (публичен по природе — виден в каждом authorization URL) и `ACCESS_KEY_HASH` (sha256, секрет не раскрывает). Секрет подставляется из ключа в рантайме — `client_config(access_key)` в `client.py`. Замерено 2026-07-24: token endpoint Google без `client_secret` отвечает `client_secret is missing`, то есть общий клиент без ключа не работает в принципе — гейт настоящий, а не декоративный. Хеш нужен только для быстрого отказа, чтобы не гонять человека через браузер ради неверного ключа. + +Раздавать ключ адресно. Утёкший ключ = доступ к общему клиенту и расход ячеек. Если Drive API не включён в Cloud Console проекте — включить в APIs & Services → Library. diff --git a/README.md b/README.md index 835a034..02da94f 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,12 @@ Upload Office files to Google Drive with automatic conversion to native Google f ## Usage ```bash -goodoc file.docx # upload and open in browser -goodoc file.xlsx --no-open # upload without opening +goodoc file.docx # upload and open in browser +goodoc file.xlsx --no-open # upload without opening goodoc file.docx file.xlsx file.pptx # upload multiple files + +goodoc login # authorize without uploading +goodoc logout # forget the stored token ``` Supported formats: @@ -47,13 +50,21 @@ pipx install git+https://github.com/djachenko/goodoc.git ## First run -On the first run, a setup wizard starts automatically: +goodoc talks to Google Drive through your own Google Cloud project. On the first run, a setup wizard starts automatically: 1. **Google Cloud credentials** — opens the browser, walks you through creating an OAuth client, prompts for the downloaded JSON file 2. **Authorization** — opens the browser for Google sign-in, saves the token After that, every run is silent. +There is also a shared client maintained by the author, with limited capacity. If you have an access key, skip the wizard entirely: + +```bash +goodoc login --key +``` + +Ask for a key in [issues](https://github.com/djachenko/goodoc/issues). Treat the key as a credential — don't publish it. + Enable the Quick Action in: System Settings → Privacy & Security → Extensions → Finder Extensions. --- @@ -68,7 +79,7 @@ goodoc-uninstall # remove goodoc, the Quick Action, and credentials To re-authorize without uninstalling: ```bash -rm ~/.config/goodoc/token.json +goodoc logout ``` --- From 1bd59f2cc82e68e6c9dd1ea816c9bbb1b789ca43 Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Tue, 28 Jul 2026 22:25:57 +0700 Subject: [PATCH 4/5] chore: pin ruff to 0.15.16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI installs linters unpinned, so ruff 0.16 broke the build the day it shipped by widening the default rule set. Six of the nine findings are present on master as well, so this is drift rather than a regression. Pin is temporary — raising it is tracked in the backlog. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d5f23c7..73d8c6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ ] [project.optional-dependencies] -test = ["pytest", "ruff", "mypy"] +test = ["pytest", "ruff==0.15.16", "mypy"] release = ["build", "python-semantic-release"] [tool.setuptools.packages.find] From 14ee82cdc0d5698a442711adcb4a70b4710760d8 Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Tue, 28 Jul 2026 22:26:04 +0700 Subject: [PATCH 5/5] refactor: use str | None instead of Optional --- src/goodoc/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/goodoc/main.py b/src/goodoc/main.py index 0bc2916..418e2fa 100644 --- a/src/goodoc/main.py +++ b/src/goodoc/main.py @@ -1,6 +1,6 @@ import webbrowser from pathlib import Path -from typing import Any, Optional +from typing import Any import typer from typer.core import TyperGroup @@ -62,7 +62,7 @@ def upload_files( @app.command() def login( - key: Optional[str] = typer.Option(None, "--key", help="Access key for the author's shared client"), + key: str | None = typer.Option(None, "--key", help="Access key for the author's shared client"), ) -> None: """Authenticate with Google (without uploading a file).""" config = Config.default()