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 ``` --- 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] 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/main.py b/src/goodoc/main.py index cdaed4e..418e2fa 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 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: 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() + + 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() 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()