Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-проект)
```

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

Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 <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.

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

---
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
54 changes: 39 additions & 15 deletions src/goodoc/auth.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions src/goodoc/client.py
Original file line number Diff line number Diff line change
@@ -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",
}
}
55 changes: 50 additions & 5 deletions src/goodoc/main.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand All @@ -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}...")
Expand All @@ -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()
50 changes: 20 additions & 30 deletions src/goodoc/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -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()

Expand All @@ -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 <file>' 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 <KEY>")
typer.echo()

acquire_credentials(config)
creds = authorize(config)
_acquire_credentials(config)

typer.echo()
typer.echo("All done. Run 'goodoc <file>' 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)
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading
Loading