diff --git a/docs/apk-repos.md b/docs/apk-repos.md index 25c762a..d04ae1d 100644 --- a/docs/apk-repos.md +++ b/docs/apk-repos.md @@ -53,8 +53,10 @@ Adding fetches and validates the complete index **before saving** the source. Without a fingerprint, Frame Control verifies the JAR signature and remembers its signer: trust on first use (TOFU). This establishes continuity with the first server response, not independent publisher identity. Obtain the published -fingerprint through a trusted channel when possible. Re-adding an existing URL -preserves its pin; changing it requires deliberately removing and re-adding it. +fingerprint through a trusted channel when possible; the store's Add a source +form shows the pinned one ("Trusted on first use: …") so you can compare it. +Re-adding an existing URL preserves its pin; changing it requires deliberately +removing and re-adding it. The API for the search/server integration is in `ui/apk_sources/fdroid.py`: `add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`, diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py index ec92e54..b7edfe1 100644 --- a/tests/test_apk_search.py +++ b/tests/test_apk_search.py @@ -243,9 +243,32 @@ class EndpointTests(SettingsTest): self.assertEqual(code, 400) self.assertIn('not available', reply['error']) mod = fake('fdroid') - mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(), Mock(), Mock() + added = {'id': 'fdroid-user-1', 'name': 'repo.example', 'fingerprint': 'ab' * 32, 'trust_on_first_use': True} + mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(return_value=added), Mock(), Mock() with patch.object(search, 'modules', return_value=([mod], [])): - self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})[0], 200) + code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'}) + self.assertEqual(code, 200) + job = self.wait(reply['job']) + self.assertEqual(job['message'], 'Added repo.example. Trusted on first use: ' + 'AB' * 32) + self.assertEqual(job['result']['source']['fingerprint'], 'ab' * 32) mod.add_repo.assert_called_once_with(url='https://repo.example/repo', fingerprint=None, name=None) + link = 'fdroidrepos://repo.example/repo?fingerprint=' + 'ab' * 32 + mod.add_repo.return_value = dict(added, trust_on_first_use=False) + job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job']) + self.assertEqual(job['message'], 'Added repo.example') + mod.add_repo.assert_called_with(url=link, fingerprint=None, name=None) + mod.add_repo.side_effect = SourceError('repository fingerprint mismatch') + job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job']) + self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix + for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'): + self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400) self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200) mod.remove_repo.assert_called_once_with(source_id='fdroid') + + def wait(self, job_id): + for _ in range(200): + job = self.request('GET', '/api/job?id=' + job_id)[1] + if job['done']: + return job + time.sleep(.01) + self.fail('job did not finish') diff --git a/ui/apk_sources/search.py b/ui/apk_sources/search.py index 197596c..ddc7289 100644 --- a/ui/apk_sources/search.py +++ b/ui/apk_sources/search.py @@ -98,10 +98,16 @@ def set_enabled(source_id, enabled): return {'message': source['name'] + (' enabled' if enabled else ' disabled')} +def repo_module(): + module = next((m for m in modules()[0] if m.KIND == 'fdroid'), None) + if module is None or not hasattr(module, 'add_repo'): + raise SourceError('User repositories are not available in this build') + return module + + def manage_repo(action, **kwargs): - mods, _ = modules() - module = next((m for m in mods if m.KIND == 'fdroid'), None) - if module is None or not hasattr(module, action): + module = repo_module() + if not hasattr(module, action): raise SourceError('User repositories are not available in this build') return getattr(module, action)(**kwargs) diff --git a/ui/index.html b/ui/index.html index 833500f..72497b2 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2080,7 +2080,14 @@ $("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail $("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();}; $("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});}; $("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});}; -$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);changeSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});}; +async function addSource(body) { + $("sourceError").textContent="Checking the repository. Large ones can take a minute…"; + let error=""; + const result=await runJob('Add repository',`repo:${body.url}`,()=>api('/api/sources',body).catch(e=>{error=e.message;throw e;}),j=>{error=j.error||"";}); + $("sourceError").textContent=result ? result.message : error || "We couldn't add that repository."; + if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();} +} +$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});}; document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close()); $('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;}); document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();}); diff --git a/ui/server.py b/ui/server.py index dbc8ad2..e374460 100755 --- a/ui/server.py +++ b/ui/server.py @@ -167,6 +167,8 @@ def start_job(label, work, progress=False): fields = {"message": result.get("message") or f"{label}: done", "result": result} except (Failure, frame_android.FrameError) as e: fields = {"error": unreachable(str(e)) or str(e)} + except SourceError as e: # already user-readable, and about a store, not the Frame + fields = {"error": str(e)} except Exception as e: fields = {"error": f"{type(e).__name__}: {e}"} finally: @@ -1278,11 +1280,20 @@ def source_manage(body): return apk_search.set_enabled(source_text(body, 'source'), body['enabled']) if action == 'add': url = source_text(body, 'url') - if urlparse(url).scheme != 'https' or not urlparse(url).hostname or urlparse(url).username: - raise Failure('Use an HTTPS repository URL without credentials', 400) - apk_search.manage_repo('add_repo', url=url, fingerprint=source_text(body, 'fingerprint', True), - name=source_text(body, 'name', True)) - return {'message': 'Repository added'} + fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True) + parts = urlparse(url) + if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username: + raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400) + apk_search.repo_module() # fail now if this build can't manage repositories + + def add(): # downloads and verifies the whole index: a job, not a request + source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name) + message = 'Added ' + source['name'] + if source.get('trust_on_first_use'): + message += '. Trusted on first use: ' + source['fingerprint'].upper() + return {'message': message, 'source': {k: source.get(k) for k in + ('id', 'name', 'fingerprint', 'trust_on_first_use')}} + return start_job('Add repository', add) if action == 'remove': apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source')) return {'message': 'Repository removed'}