mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 14:48:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
838f0b9d8a | ||
|
|
1b9ecc0f19 | ||
|
|
7685e94ca5 |
@@ -77,6 +77,25 @@ def describe_exception(exc: BaseException,
|
|||||||
"""
|
"""
|
||||||
message = str(exc).strip()
|
message = str(exc).strip()
|
||||||
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
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
|
# Order matters: the URL and header forms are more specific than the
|
||||||
# generic key=value pattern, which would otherwise chew the scheme.
|
# generic key=value pattern, which would otherwise chew the scheme.
|
||||||
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
"""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 <input type="text">. 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_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)
|
||||||
|
pages = items if isinstance(items, list) and items and isinstance(items[0], dict) \
|
||||||
|
else items
|
||||||
|
if isinstance(pages, list):
|
||||||
|
pages = [{'items': pages}]
|
||||||
|
|
||||||
|
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 '<redacted>' 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 '<redacted>' in body['message'], body
|
||||||
@@ -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.api_helpers import success_response, error_response, validate_request_json
|
||||||
from src.web_interface.errors import ErrorCode
|
from src.web_interface.errors import ErrorCode
|
||||||
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
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.plugin_system.operation_types import OperationType
|
||||||
from src.web_interface.validators import (
|
from src.web_interface.validators import (
|
||||||
validate_file_upload
|
validate_file_upload
|
||||||
@@ -7312,6 +7312,222 @@ def upload_calendar_credentials():
|
|||||||
logger.error('Error in upload_calendar_credentials', exc_info=True)
|
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
|
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:
|
||||||
|
return None, 'Could not run the authentication script: %s' % 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',
|
||||||
|
'message': ('The Google API libraries are not installed. Install '
|
||||||
|
"the calendar plugin's requirements.txt. (%s)" % 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'])
|
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
||||||
def delete_plugin_asset():
|
def delete_plugin_asset():
|
||||||
"""Delete an asset file for a plugin"""
|
"""Delete an asset file for a plugin"""
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* 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 = '<i class="fas fa-key"></i> Connect Google Account';
|
||||||
|
|
||||||
|
const status = document.createElement('p');
|
||||||
|
status.className = 'text-xs text-gray-400 mt-2';
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<p class="text-sm text-amber-300 font-semibold">'
|
||||||
|
+ '<i class="fas fa-triangle-exclamation"></i> '
|
||||||
|
+ 'The next page will fail to load. That is expected.</p>'
|
||||||
|
+ '<p class="text-xs text-amber-200/90 mt-1">'
|
||||||
|
+ 'After you approve access, Google sends your browser to '
|
||||||
|
+ '<code>127.0.0.1</code>, where nothing is running \u2014 so you will see '
|
||||||
|
+ '"This site can\u2019t be reached" or similar. Nothing has gone wrong. '
|
||||||
|
+ 'Copy the <strong>entire address</strong> out of the address bar '
|
||||||
|
+ '(it contains <code>?code=...</code>) and paste it in the box below.</p>';
|
||||||
|
|
||||||
|
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:';
|
||||||
|
|
||||||
|
const codeInput = document.createElement('input');
|
||||||
|
codeInput.type = 'text';
|
||||||
|
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 = '<i class="fas fa-check"></i> 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 : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -987,6 +987,7 @@
|
|||||||
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-oauth.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
|
||||||
|
|||||||
@@ -815,7 +815,7 @@
|
|||||||
<i class="fas fa-info-circle mr-1"></i>
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
Changes in the file manager save immediately — no need to click Save Configuration.
|
Changes in the file manager save immediately — no need to click Save Configuration.
|
||||||
</p>
|
</p>
|
||||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %}
|
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager', 'google-oauth'] %}
|
||||||
{# Render widget container #}
|
{# Render widget container #}
|
||||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user