mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
Store: add repositories in a background job and show the TOFU fingerprint
Adding a repository downloads and verifies its whole index, so it now runs as a job (runJob in the UI) and reports 'Trusted on first use: <fingerprint>' when no pin was given. fdroidrepos:// links pass the server check, as documented. Jobs report SourceError messages without a 'SourceError:' prefix. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ddcf3b2ad2
commit
a9d78679ec
5 files changed
+62
-13
No files matched your search
+4
-2
@@ -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)`,
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
|
||||
+8
-1
@@ -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();});
|
||||
|
||||
+16
-5
@@ -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'}
|
||||
|
||||
Reference in new issue
Block a user