diff --git a/src/web_interface/error_handler.py b/src/web_interface/error_handler.py
index ea6423a4..706bd747 100644
--- a/src/web_interface/error_handler.py
+++ b/src/web_interface/error_handler.py
@@ -77,6 +77,25 @@ def describe_exception(exc: BaseException,
"""
message = str(exc).strip()
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
+ return redact_text(text, max_length)
+
+
+def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
+ """Make arbitrary text safe to hand back over HTTP.
+
+ Split out of describe_exception because exceptions are not the only thing
+ worth returning: a subprocess's stderr, or a message a helper script
+ printed, is just as useful to a user and just as capable of carrying a
+ token or a password in it.
+
+ Args:
+ text: The text to redact
+ max_length: Truncate beyond this many characters
+
+ Returns:
+ A single line, credentials replaced, length capped.
+ """
+ text = text or ''
# Order matters: the URL and header forms are more specific than the
# generic key=value pattern, which would otherwise chew the scheme.
text = _REDACT_URL_USERINFO.sub(r'\1\3', text)
diff --git a/test/web_interface/test_calendar_oauth_endpoints.py b/test/web_interface/test_calendar_oauth_endpoints.py
new file mode 100644
index 00000000..9520be5f
--- /dev/null
+++ b/test/web_interface/test_calendar_oauth_endpoints.py
@@ -0,0 +1,408 @@
+"""Tests the calendar plugin's OAuth and calendar-listing endpoints.
+
+The plugin's config UI advertised a three-step setup, but only step 1 existed
+on the server. Step 3's picker fetched /api/v3/plugins/calendar/list-calendars,
+which was never registered, so Flask fell through to the global 404 handler and
+the user saw "Resource not found" — with nothing to say which resource. Step 2
+had no endpoint either, and no field in the schema at all, even though the
+plugin ships calendar_registration.py written expressly for a web-driven
+two-step flow.
+
+These cover the two new routes: that they exist, that they fail with something
+actionable rather than a bare 404, and that the shapes the widgets consume are
+what the server actually sends.
+"""
+
+import json
+import pickle
+import sys
+from pathlib import Path
+
+import pytest
+
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from web_interface.blueprints import api_v3 as mod # noqa: E402
+
+
+@pytest.fixture
+def client(monkeypatch, tmp_path):
+ """A test client whose calendar plugin lives in tmp_path."""
+ from flask import Flask
+
+ plugin_dir = tmp_path / 'calendar'
+ plugin_dir.mkdir()
+
+ app = Flask(__name__)
+ app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
+ app.config['TESTING'] = True
+ monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: plugin_dir)
+ with app.test_client() as c:
+ c.plugin_dir = plugin_dir
+ yield c
+
+
+@pytest.fixture
+def uninstalled(monkeypatch):
+ from flask import Flask
+
+ app = Flask(__name__)
+ app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
+ app.config['TESTING'] = True
+ monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: None)
+ with app.test_client() as c:
+ yield c
+
+
+class TestTheRoutesExistAtAll:
+ """The original bug: the URLs the widgets call were not registered."""
+
+ def test_list_calendars_is_routed(self, client):
+ response = client.get('/api/v3/plugins/calendar/list-calendars')
+ # Reaching the handler is the whole point; what it then says about
+ # missing setup is TestItSaysWhatIsWrong's business.
+ assert response.status_code != 404, "still unrouted"
+ assert response.get_json()['message'] != 'Resource not found'
+
+ def test_authenticate_is_routed(self, client):
+ response = client.post('/api/v3/plugins/calendar/authenticate', json={})
+ assert response.status_code != 404, "still unrouted"
+ assert response.get_json()['message'] != 'Resource not found'
+
+ def test_both_urls_match_what_the_widgets_request(self):
+ # The widgets hardcode these; a rename on either side reintroduces the
+ # original bug silently.
+ picker = Path(project_root) / 'web_interface/static/v3/js/widgets/google-calendar-picker.js'
+ oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
+ assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
+ assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
+ source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
+ assert "'/plugins/calendar/list-calendars'" in source
+ assert "'/plugins/calendar/authenticate'" in source
+
+ def test_the_oauth_widget_is_dispatched_not_rendered_as_a_text_box(self):
+ # The string branch of the config template dispatches on an allow-list
+ # of widget names; anything missing from it silently falls through to a
+ # plain . That produced two boxes on the calendar
+ # page -- the widget's own, and a stray one for the same field -- and
+ # no way to tell which to paste into.
+ template = (Path(project_root)
+ / 'web_interface/templates/v3/partials/plugin_config.html'
+ ).read_text(encoding='utf-8')
+ allow_list_line = [ln for ln in template.splitlines()
+ if "str_widget in [" in ln]
+ assert allow_list_line, "the string widget allow-list moved"
+ assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
+
+ def test_the_widget_script_is_served(self):
+ base = (Path(project_root) / 'web_interface/templates/v3/base.html'
+ ).read_text(encoding='utf-8')
+ assert 'widgets/google-oauth.js' in base
+
+ def test_the_status_line_is_announced(self):
+ # Every message the widget gives arrives after an async call, so a
+ # screen reader hears nothing unless the element is a live region.
+ widget = (Path(project_root)
+ / 'web_interface/static/v3/js/widgets/google-oauth.js'
+ ).read_text(encoding='utf-8')
+ # Both attributes must be on the *status* element. Searching for them
+ # separately would pass with each on a different node, which announces
+ # nothing.
+ assert "status.setAttribute('role', 'status')" in widget, widget[:0]
+ assert "status.setAttribute('aria-live', 'polite')" in widget
+
+ def test_the_paste_box_has_an_accessible_name(self):
+ # A visible label is not enough on its own: without the association the
+ # input's only name is a placeholder, which vanishes on focus -- which
+ # is exactly when the value is being pasted.
+ widget = (Path(project_root)
+ / 'web_interface/static/v3/js/widgets/google-oauth.js'
+ ).read_text(encoding='utf-8')
+ # The binding is what matters, not that both lines exist: a `for` and
+ # an `id` that disagree leave the input just as anonymous. Both must
+ # go through the same identifier.
+ import re as _re
+ for_target = _re.search(r"codeLabel\.setAttribute\('for',\s*(\w+)\)", widget)
+ id_source = _re.search(r"codeInput\.id\s*=\s*(\w+)", widget)
+ assert for_target and id_source, (for_target, id_source)
+ assert for_target.group(1) == id_source.group(1), (
+ "label points at %r but the input is %r"
+ % (for_target.group(1), id_source.group(1)))
+
+ def test_the_failed_page_is_called_out_loudly(self):
+ # The loopback redirect lands on a browser error page at exactly the
+ # moment the user has to act. In small grey text it gets missed and the
+ # flow reads as broken while it is working.
+ widget = (Path(project_root)
+ / 'web_interface/static/v3/js/widgets/google-oauth.js'
+ ).read_text(encoding='utf-8')
+ assert 'expected' in widget.lower()
+ assert 'amber' in widget, "the warning is not visually distinguished"
+
+
+class TestItSaysWhatIsWrong:
+ def test_listing_without_a_token_asks_for_step_2(self, client):
+ response = client.get('/api/v3/plugins/calendar/list-calendars')
+ assert response.status_code == 400
+ body = response.get_json()
+ assert body['status'] == 'error'
+ assert 'step 2' in body['message'].lower(), body['message']
+
+ def test_authenticating_without_credentials_asks_for_step_1(self, client):
+ response = client.post('/api/v3/plugins/calendar/authenticate', json={})
+ assert response.status_code == 400
+ assert 'step 1' in response.get_json()['message'].lower()
+
+ def test_an_uninstalled_plugin_says_so(self, uninstalled):
+ for response in (
+ uninstalled.get('/api/v3/plugins/calendar/list-calendars'),
+ uninstalled.post('/api/v3/plugins/calendar/authenticate', json={}),
+ ):
+ assert response.status_code == 404
+ # A 404 here is honest -- but it must name the plugin, not read as
+ # the generic "Resource not found" that started this.
+ assert 'not installed' in response.get_json()['message'].lower()
+
+
+class TestTheScriptRunner:
+ def test_it_returns_the_json_the_script_prints(self, tmp_path):
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text(
+ 'print(\'{"status": "success", "auth_url": "https://x"}\')\n',
+ encoding='utf-8')
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert error is None
+ assert payload['auth_url'] == 'https://x'
+
+ def test_it_ignores_noise_before_the_json(self, tmp_path):
+ # An import warning or a library writing to stdout would otherwise
+ # make the last-line parse fail.
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text(
+ 'print("some library warning")\n'
+ 'print(\'{"status": "success"}\')\n', encoding='utf-8')
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert error is None and payload['status'] == 'success'
+
+ def test_it_passes_stdin_through(self, tmp_path):
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text(
+ 'import sys, json\n'
+ 'print(json.dumps({"status": "success", "got": sys.stdin.read().strip()}))\n',
+ encoding='utf-8')
+ payload, _ = mod._run_calendar_registration(tmp_path, 'http://127.0.0.1/?code=abc')
+ assert payload['got'] == 'http://127.0.0.1/?code=abc'
+
+ def test_a_missing_script_is_reported(self, tmp_path):
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert payload is None
+ assert 'script not found' in error.lower()
+
+ def test_output_that_is_not_json_is_reported_with_context(self, tmp_path):
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text('import sys\nsys.stderr.write("boom\\n")\n', encoding='utf-8')
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert payload is None
+ assert 'no result' in error.lower()
+ assert 'boom' in error
+
+
+class TestListingShape:
+ """The picker reads cal.id, cal.summary and cal.primary."""
+
+ def _authenticate(self, client, monkeypatch, items):
+ creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
+ (client.plugin_dir / 'token.pickle').write_bytes(pickle.dumps({'x': 1}))
+ monkeypatch.setattr(mod.pickle if hasattr(mod, 'pickle') else pickle,
+ 'loads', lambda *a, **k: creds, raising=False)
+
+ import types
+ fake_pickle = types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
+ # Callers pass a flat list of calendars; the API returns them wrapped
+ # in a page. One page is all these cases need -- TestPagination builds
+ # its own multi-page sequences.
+ pages = [{'items': items}]
+
+ state = {'i': 0}
+
+ def fake_list(**kwargs):
+ page = pages[min(state['i'], len(pages) - 1)]
+ state['i'] += 1
+ return types.SimpleNamespace(execute=lambda: page)
+
+ def fake_build(*args, **kwargs):
+ return types.SimpleNamespace(
+ calendarList=lambda: types.SimpleNamespace(list=fake_list))
+
+ real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
+ else __builtins__.__import__
+
+ def fake_import(name, *args, **kwargs):
+ if name == 'pickle':
+ return fake_pickle
+ if name == 'google.auth.transport.requests':
+ return types.SimpleNamespace(Request=object)
+ if name == 'googleapiclient.discovery':
+ return types.SimpleNamespace(build=fake_build)
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr('builtins.__import__', fake_import)
+
+ def test_it_returns_id_summary_and_primary(self, client, monkeypatch):
+ self._authenticate(client, monkeypatch, [
+ {'id': 'b@x', 'summary': 'Work'},
+ {'id': 'a@x', 'summary': 'Personal', 'primary': True},
+ ])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert body['status'] == 'success'
+ assert {c['id'] for c in body['calendars']} == {'a@x', 'b@x'}
+ assert all(set(c) == {'id', 'summary', 'primary'} for c in body['calendars'])
+
+ def test_the_primary_calendar_comes_first(self, client, monkeypatch):
+ # Short list, but the one the user wants is almost always their own.
+ self._authenticate(client, monkeypatch, [
+ {'id': 'z@x', 'summary': 'Aardvarks'},
+ {'id': 'a@x', 'summary': 'Zebras', 'primary': True},
+ ])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert body['calendars'][0]['id'] == 'a@x'
+ assert body['calendars'][0]['primary'] is True
+
+ def test_a_calendar_without_a_name_still_lists(self, client, monkeypatch):
+ self._authenticate(client, monkeypatch, [{'id': 'noname@x'}])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert body['calendars'][0]['summary'] == 'noname@x'
+
+ def test_entries_without_an_id_are_dropped(self, client, monkeypatch):
+ # Nothing could be selected by such a row, and the checkbox value
+ # would be undefined.
+ self._authenticate(client, monkeypatch, [{'summary': 'ghost'}, {'id': 'real@x'}])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert [c['id'] for c in body['calendars']] == ['real@x']
+
+
+class TestPagination:
+ """calendarList.list pages at 250 and defaults to 100."""
+
+ def _paged(self, client, monkeypatch, pages):
+ import types
+ creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
+ (client.plugin_dir / 'token.pickle').write_bytes(b'x')
+ state = {'i': 0}
+ seen = []
+
+ def fake_list(**kwargs):
+ seen.append(kwargs)
+ page = pages[min(state['i'], len(pages) - 1)]
+ state['i'] += 1
+ return types.SimpleNamespace(execute=lambda: page)
+
+ def fake_build(*args, **kwargs):
+ return types.SimpleNamespace(
+ calendarList=lambda: types.SimpleNamespace(list=fake_list))
+
+ real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
+ else __builtins__.__import__
+
+ def fake_import(name, *args, **kwargs):
+ if name == 'pickle':
+ return types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
+ if name == 'google.auth.transport.requests':
+ return types.SimpleNamespace(Request=object)
+ if name == 'googleapiclient.discovery':
+ return types.SimpleNamespace(build=fake_build)
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr('builtins.__import__', fake_import)
+ return seen
+
+ def test_every_page_is_collected(self, client, monkeypatch):
+ # Taking only the first page would hide calendars from the picker with
+ # nothing to say the list was cut short.
+ self._paged(client, monkeypatch, [
+ {'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 't1'},
+ {'items': [{'id': 'b@x', 'summary': 'B'}], 'nextPageToken': 't2'},
+ {'items': [{'id': 'c@x', 'summary': 'C'}]},
+ ])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert [c['id'] for c in body['calendars']] == ['a@x', 'b@x', 'c@x']
+
+ def test_the_page_token_is_passed_back(self, client, monkeypatch):
+ seen = self._paged(client, monkeypatch, [
+ {'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'tok'},
+ {'items': [{'id': 'b@x', 'summary': 'B'}]},
+ ])
+ client.get('/api/v3/plugins/calendar/list-calendars')
+ assert seen[0]['pageToken'] is None
+ assert seen[1]['pageToken'] == 'tok'
+ assert all(k['maxResults'] == 250 for k in seen)
+
+ def test_a_looping_token_cannot_spin_forever(self, client, monkeypatch):
+ # Every page claims another follows.
+ self._paged(client, monkeypatch, [
+ {'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'same'},
+ ])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert body['status'] == 'success'
+ assert len(body['calendars']) <= mod._CALENDAR_LIST_MAX_PAGES
+
+
+class TestDiagnosticsAreRedacted:
+ def test_script_stderr_is_redacted_on_the_way_out(self, tmp_path):
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text(
+ 'import sys\n'
+ 'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
+ encoding='utf-8')
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert payload is None
+ assert 'hunter2' not in error, error
+ assert '' in error, error
+
+ def test_a_failing_script_payload_is_redacted(self, client):
+ (client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
+ (client.plugin_dir / 'calendar_registration.py').write_text(
+ 'import json\n'
+ 'print(json.dumps({"status": "error", '
+ '"message": "Failed: client_secret=topsecret"}))\n',
+ encoding='utf-8')
+ body = client.post('/api/v3/plugins/calendar/authenticate',
+ json={}).get_json()
+ assert body['status'] == 'error'
+ assert 'topsecret' not in json.dumps(body), body
+ assert '' in body['message'], body
+
+ def test_an_unrunnable_script_is_reported_without_raw_exception_text(self,
+ tmp_path,
+ monkeypatch):
+ # OSError from the spawn carries the interpreter path and whatever the
+ # OS chose to say; it reaches the client through the redactor like
+ # everything else.
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text('', encoding='utf-8')
+
+ def boom(*a, **k):
+ raise OSError("Exec format error: token=abcd1234 /usr/bin/python3")
+
+ monkeypatch.setattr(mod.subprocess, 'run', boom)
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert payload is None
+ assert 'abcd1234' not in error, error
+ assert 'OSError' in error, error
+
+ def test_a_missing_google_library_is_reported_without_raw_exception_text(
+ self, client, monkeypatch):
+ (client.plugin_dir / 'token.pickle').write_bytes(b'x')
+ real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
+ else __builtins__.__import__
+
+ def fake_import(name, *args, **kwargs):
+ if name.startswith('google'):
+ raise ImportError("No module named 'google' password=hunter2")
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr('builtins.__import__', fake_import)
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert 'hunter2' not in json.dumps(body), body
+ assert 'requirements.txt' in body['message']
diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py
index 06fef117..984637e8 100644
--- a/web_interface/blueprints/api_v3.py
+++ b/web_interface/blueprints/api_v3.py
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
from src.web_interface.errors import ErrorCode
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
-from src.web_interface.error_handler import describe_exception
+from src.web_interface.error_handler import describe_exception, redact_text
from src.plugin_system.operation_types import OperationType
from src.web_interface.validators import (
validate_file_upload
@@ -7317,6 +7317,227 @@ def upload_calendar_credentials():
logger.error('Error in upload_calendar_credentials', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
+# calendarList.list pages at 250 entries maximum. Ten pages is far past any
+# real account and exists only so a malformed nextPageToken cannot spin here.
+_CALENDAR_LIST_MAX_PAGES = 10
+
+
+def _calendar_plugin_dir() -> Optional[Path]:
+ """Where the calendar plugin is installed, or None if it is not."""
+ if api_v3.plugin_manager:
+ plugin_dir = api_v3.plugin_manager.get_plugin_directory('calendar')
+ else:
+ plugin_dir = PROJECT_ROOT / 'plugins' / 'calendar'
+ if not plugin_dir:
+ return None
+ plugin_dir = Path(plugin_dir)
+ return plugin_dir if plugin_dir.exists() else None
+
+
+def _run_calendar_registration(plugin_dir: Path, stdin_payload: str):
+ """Run the plugin's OAuth script and return the JSON object it prints.
+
+ The script decides between web and terminal mode by whether stdin is a
+ tty, so it must be given a pipe. It emits one JSON object on stdout; the
+ last parsable line is taken, because an import warning or a library's
+ stderr redirection can land in front of it.
+
+ Returns (payload, error_message). Exactly one is None.
+ """
+ script = plugin_dir / 'calendar_registration.py'
+ if not script.exists():
+ return None, 'Authentication script not found in the calendar plugin'
+
+ try:
+ result = subprocess.run( # nosec B603 - fixed script path inside the plugin dir
+ [sys.executable, str(script)],
+ input=stdin_payload,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ cwd=str(plugin_dir),
+ )
+ except subprocess.TimeoutExpired:
+ return None, 'Authentication timed out after 120s'
+ except OSError as e:
+ logger.error('Could not run calendar_registration.py', exc_info=True)
+ return None, 'Could not run the authentication script: %s' % describe_exception(e)
+
+ for line in reversed((result.stdout or '').splitlines()):
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(payload, dict):
+ return payload, None
+
+ raw = (result.stderr or result.stdout or '').strip()
+ # The unredacted text goes to the log, where it is worth having in full.
+ # What comes back over HTTP is redacted: this is a script that handles
+ # OAuth client secrets, and its stderr can quote them.
+ if raw:
+ logger.error('calendar_registration.py failed (exit %s): %s',
+ result.returncode, raw)
+ return None, 'Authentication script produced no result%s' % (
+ ': %s' % redact_text(raw) if raw else '')
+
+
+@api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
+def authenticate_calendar():
+ """Google OAuth for the calendar plugin, in the two steps it requires.
+
+ Step 1 (no body) returns the consent URL to open. Step 2 posts back the
+ URL Google redirected to -- it fails to load, because the redirect points
+ at a loopback address nothing is listening on, but the address bar carries
+ the authorization code -- and the script exchanges it for a token.
+
+ Two calls rather than one because the user has to visit Google in between.
+ The script persists the PKCE verifier from step 1 for step 2 to reuse; the
+ exchange fails with "Missing code verifier" otherwise.
+ """
+ try:
+ plugin_dir = _calendar_plugin_dir()
+ if plugin_dir is None:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'The calendar plugin is not installed'
+ }), 404
+
+ if not (plugin_dir / 'credentials.json').exists():
+ return jsonify({
+ 'status': 'error',
+ 'message': ('No credentials.json yet. Upload your Google OAuth '
+ 'client file first (Step 1).')
+ }), 400
+
+ data = request.get_json(silent=True) or {}
+ redirect_url = (data.get('redirect_url') or data.get('code') or '').strip()
+
+ payload, error = _run_calendar_registration(plugin_dir, redirect_url)
+ if error:
+ return jsonify({'status': 'error', 'message': error}), 500
+ if payload.get('status') != 'success':
+ # The script's own diagnosis is more useful than anything that
+ # could be reconstructed here -- but it interpolates exceptions
+ # into its messages, so it reaches the client redacted and the
+ # original goes to the log.
+ logger.error('calendar authentication failed: %s', payload)
+ safe = dict(payload)
+ safe['message'] = redact_text(str(payload.get('message', '')
+ or 'Authentication failed'))
+ return jsonify(safe), 400
+ return jsonify(payload)
+
+ except Exception as e:
+ logger.error('Error in authenticate_calendar', exc_info=True)
+ return jsonify({'status': 'error',
+ 'message': 'An error occurred; see logs for details',
+ 'details': describe_exception(e)}), 500
+
+
+@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
+def list_calendar_calendars():
+ """The calendars this account can see, for the config picker.
+
+ Reads the token the OAuth flow wrote rather than shelling out again: the
+ picker is used interactively and a subprocess per click is slower than the
+ API call it would be wrapping.
+ """
+ try:
+ plugin_dir = _calendar_plugin_dir()
+ if plugin_dir is None:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'The calendar plugin is not installed'
+ }), 404
+
+ token_file = plugin_dir / 'token.pickle'
+ if not token_file.exists():
+ return jsonify({
+ 'status': 'error',
+ 'message': ('Not authenticated with Google yet. Complete Step 2 '
+ 'first, then load your calendars.')
+ }), 400
+
+ try:
+ import pickle
+ from google.auth.transport.requests import Request as GoogleRequest
+ from googleapiclient.discovery import build as build_google_service
+ except ImportError as e:
+ return jsonify({
+ 'status': 'error',
+ # The name of the missing module is the whole diagnosis, but it
+ # arrives as an exception, so it goes through the redactor like
+ # any other -- an ImportError can quote a path.
+ 'message': ('The Google API libraries are not installed. Install '
+ "the calendar plugin's requirements.txt. (%s)"
+ % describe_exception(e))
+ }), 500
+
+ with open(token_file, 'rb') as handle:
+ # Written only by this plugin's own OAuth flow, into its own
+ # directory, and read here exactly as the plugin itself reads it.
+ creds = pickle.load(handle) # nosec B301 - locally generated token
+
+ if creds and creds.expired and creds.refresh_token:
+ creds.refresh(GoogleRequest())
+ with open(token_file, 'wb') as handle:
+ pickle.dump(creds, handle)
+ os.chmod(token_file, 0o600)
+
+ if not creds or not creds.valid:
+ return jsonify({
+ 'status': 'error',
+ 'message': ('Stored Google credentials are no longer valid. '
+ 'Run Step 2 again to re-authenticate.')
+ }), 400
+
+ service = build_google_service('calendar', 'v3', credentials=creds)
+
+ # calendarList.list returns 100 entries per page by default and caps at
+ # 250, handing back a nextPageToken when there are more. Taking only
+ # the first page would silently hide calendars from the picker, and the
+ # user would have no way to tell the list was truncated.
+ entries = []
+ page_token = None
+ for _ in range(_CALENDAR_LIST_MAX_PAGES):
+ response = service.calendarList().list(
+ maxResults=250, pageToken=page_token).execute()
+ entries.extend(response.get('items', []))
+ page_token = response.get('nextPageToken')
+ if not page_token:
+ break
+ else:
+ # 2500 calendars in, something is wrong with the account or the
+ # token is looping; show what was collected rather than spin.
+ logger.warning(
+ 'calendarList paging stopped at %d pages with more remaining',
+ _CALENDAR_LIST_MAX_PAGES)
+
+ calendars = [{
+ 'id': entry.get('id'),
+ # The picker labels each row with summary and falls back to the id
+ # only in its own display, so send something either way.
+ 'summary': entry.get('summary') or entry.get('id'),
+ 'primary': bool(entry.get('primary', False)),
+ } for entry in entries if entry.get('id')]
+
+ # Primary first, then alphabetically: the list is usually short but the
+ # one the user wants is almost always their own calendar.
+ calendars.sort(key=lambda c: (not c['primary'], c['summary'].lower()))
+
+ return jsonify({'status': 'success', 'calendars': calendars})
+
+ except Exception as e:
+ logger.error('Error in list_calendar_calendars', exc_info=True)
+ return jsonify({'status': 'error',
+ 'message': 'An error occurred; see logs for details',
+ 'details': describe_exception(e)}), 500
+
+
@api_v3.route('/plugins/assets/delete', methods=['POST'])
def delete_plugin_asset():
"""Delete an asset file for a plugin"""
diff --git a/web_interface/static/v3/js/widgets/google-oauth.js b/web_interface/static/v3/js/widgets/google-oauth.js
new file mode 100644
index 00000000..f920b7f9
--- /dev/null
+++ b/web_interface/static/v3/js/widgets/google-oauth.js
@@ -0,0 +1,196 @@
+/**
+ * Google OAuth Widget
+ *
+ * Step 2 of the calendar plugin's setup, between uploading the OAuth client
+ * file and picking calendars. Google will not let a headless device complete
+ * consent on its own, so the flow is necessarily two calls with a human in
+ * between:
+ *
+ * 1. POST /api/v3/plugins/calendar/authenticate with no body
+ * -> { auth_url } to open in a browser
+ * 2. the browser lands on a loopback address that fails to load; its URL
+ * carries the authorization code. POST it back as redirect_url
+ * -> the server exchanges it and writes token.pickle
+ *
+ * The failed page in step 2 is expected and is worth saying out loud, because
+ * it looks exactly like something went wrong.
+ *
+ * @module GoogleOAuthWidget
+ */
+
+(function () {
+ 'use strict';
+
+ if (typeof window.LEDMatrixWidgets === 'undefined') {
+ console.error('[GoogleOAuthWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
+ return;
+ }
+
+ const ENDPOINT = '/api/v3/plugins/calendar/authenticate';
+
+ window.LEDMatrixWidgets.register('google-oauth', {
+ name: 'Google OAuth Widget',
+ version: '1.0.0',
+
+ /**
+ * @param {HTMLElement} container
+ * @param {Object} config - schema config (unused)
+ * @param {*} value - unused; this widget stores nothing
+ * @param {Object} options - { fieldId, pluginId, name }
+ */
+ render: function (container, config, value, options) {
+ const fieldId = options.fieldId;
+
+ // Nothing is stored in config by this step -- the result is
+ // token.pickle on the device -- but the form still expects a field.
+ const hidden = document.createElement('input');
+ hidden.type = 'hidden';
+ hidden.id = fieldId + '_hidden';
+ hidden.name = options.name;
+ hidden.value = value || '';
+
+ const startBtn = document.createElement('button');
+ startBtn.type = 'button';
+ startBtn.className = 'px-3 py-1.5 text-sm rounded-md bg-blue-600 hover:bg-blue-700 text-white';
+ startBtn.innerHTML = ' Connect Google Account';
+
+ const status = document.createElement('p');
+ status.className = 'text-xs text-gray-400 mt-2';
+ // Every message this widget gives -- the consent link is ready,
+ // the exchange failed -- arrives here after an async call, so a
+ // screen reader is told nothing unless it is a live region.
+ status.setAttribute('role', 'status');
+ status.setAttribute('aria-live', 'polite');
+
+ const step2 = document.createElement('div');
+ step2.className = 'mt-3 hidden';
+
+ const link = document.createElement('a');
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.className = 'text-blue-400 underline text-sm break-all';
+ link.textContent = 'Open the Google consent screen';
+
+ // Deliberately loud. After consent the browser is redirected to a
+ // loopback address nothing is listening on, so it lands on a
+ // browser error page -- which reads as a failure at exactly the
+ // moment the user has to act on it. Said quietly in grey it gets
+ // missed, and the flow looks broken when it is working.
+ const hint = document.createElement('div');
+ hint.className =
+ 'mt-3 p-3 rounded-md border border-amber-500/60 bg-amber-500/10';
+ hint.innerHTML =
+ '
'
+ + ' '
+ + 'The next page will fail to load. That is expected.
'
+ + '
'
+ + 'After you approve access, Google sends your browser to '
+ + '127.0.0.1, where nothing is running \u2014 so you will see '
+ + '"This site can\u2019t be reached" or similar. Nothing has gone wrong. '
+ + 'Copy the entire address out of the address bar '
+ + '(it contains ?code=...) and paste it in the box below.
';
+
+ const codeInputId = fieldId + '_redirect_url';
+
+ const codeLabel = document.createElement('label');
+ codeLabel.className = 'block text-xs text-gray-300 mt-3';
+ codeLabel.textContent = 'Paste the address from that failed page here:';
+ // The label was visible but not associated, so the input still had
+ // no accessible name -- a placeholder is not one, and it vanishes
+ // on focus, which is exactly when the value is being pasted.
+ codeLabel.setAttribute('for', codeInputId);
+
+ const codeInput = document.createElement('input');
+ codeInput.type = 'text';
+ codeInput.id = codeInputId;
+ codeInput.placeholder = 'http://127.0.0.1/?code=...';
+ codeInput.className =
+ 'mt-2 block w-full px-3 py-2 text-sm border border-gray-600 '
+ + 'rounded-md bg-gray-800 text-gray-100';
+
+ const finishBtn = document.createElement('button');
+ finishBtn.type = 'button';
+ finishBtn.className = 'mt-2 px-3 py-1.5 text-sm rounded-md bg-green-600 hover:bg-green-700 text-white';
+ finishBtn.innerHTML = ' Finish Authentication';
+
+ function say(message, kind) {
+ status.textContent = message;
+ status.className = 'text-xs mt-2 ' + (
+ kind === 'error' ? 'text-red-400'
+ : kind === 'success' ? 'text-green-400'
+ : 'text-gray-400');
+ }
+
+ function post(body) {
+ return fetch(ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body || {})
+ }).then(function (r) {
+ return r.json().catch(function () {
+ // A non-JSON body here means the request never reached
+ // the handler -- worth saying so rather than "undefined".
+ return { status: 'error', message: 'Server returned ' + r.status };
+ });
+ });
+ }
+
+ startBtn.addEventListener('click', function () {
+ startBtn.disabled = true;
+ say('Requesting a consent link...');
+ post({}).then(function (data) {
+ startBtn.disabled = false;
+ if (data.status !== 'success' || !data.auth_url) {
+ say(data.message || 'Could not start authentication.', 'error');
+ return;
+ }
+ link.href = data.auth_url;
+ step2.classList.remove('hidden');
+ say(data.message || 'Open the link, approve, then paste the address back.');
+ }).catch(function (err) {
+ startBtn.disabled = false;
+ say('Request failed: ' + err.message, 'error');
+ });
+ });
+
+ finishBtn.addEventListener('click', function () {
+ const pasted = codeInput.value.trim();
+ if (!pasted) {
+ say('Paste the address your browser was redirected to.', 'error');
+ return;
+ }
+ finishBtn.disabled = true;
+ say('Exchanging the code with Google...');
+ post({ redirect_url: pasted }).then(function (data) {
+ finishBtn.disabled = false;
+ if (data.status !== 'success') {
+ say(data.message || 'Authentication failed.', 'error');
+ return;
+ }
+ say(data.message || 'Authenticated.', 'success');
+ step2.classList.add('hidden');
+ codeInput.value = '';
+ }).catch(function (err) {
+ finishBtn.disabled = false;
+ say('Request failed: ' + err.message, 'error');
+ });
+ });
+
+ step2.appendChild(link);
+ step2.appendChild(hint);
+ step2.appendChild(codeLabel);
+ step2.appendChild(codeInput);
+ step2.appendChild(finishBtn);
+
+ container.appendChild(hidden);
+ container.appendChild(startBtn);
+ container.appendChild(status);
+ container.appendChild(step2);
+ },
+
+ getValue: function (fieldId) {
+ const hidden = document.getElementById(fieldId + '_hidden');
+ return hidden ? hidden.value : '';
+ }
+ });
+})();
diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html
index 28ed48b9..fb4cfa6a 100644
--- a/web_interface/templates/v3/base.html
+++ b/web_interface/templates/v3/base.html
@@ -987,6 +987,7 @@
+
diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html
index 41651fb8..f789fd5b 100644
--- a/web_interface/templates/v3/partials/plugin_config.html
+++ b/web_interface/templates/v3/partials/plugin_config.html
@@ -815,7 +815,7 @@
Changes in the file manager save immediately — no need to click Save Configuration.