mirror of
https://github.com/chatmail/relay.git
synced 2026-09-23 15:50:09 +00:00
feat: serve an APPVERSIONS.json index file to clients via IMAP metadata
This is designed to help implement self-updating APKs (and later other clients), see counterpart https://github.com/chatmail/core/pull/8557
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
include src/chatmaild/defaults/*.json
|
||||
include src/chatmaild/ini/*.ini.f
|
||||
include src/chatmaild/ini/*.ini
|
||||
include src/chatmaild/tests/mail-data/*
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "deltachat",
|
||||
"sources": [
|
||||
{
|
||||
"sourceId": "gplay",
|
||||
"versionInteger": 754,
|
||||
"versionString": "2.57.0",
|
||||
"downloadUrl": "https://github.com/deltachat/deltachat-android/releases/download/v2.57.0/deltachat-gplay-release-2.57.0.apk"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from importlib.resources import files
|
||||
|
||||
from .config import read_config
|
||||
from .dictproxy import DictProxy
|
||||
@@ -18,6 +20,18 @@ def turn_credentials(turn_socket_path):
|
||||
return file.readline().decode("utf-8").strip()
|
||||
|
||||
|
||||
def read_appversions(path):
|
||||
try:
|
||||
data = json.loads(path.read_bytes())
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError):
|
||||
logging.exception(f"failed to read {path}")
|
||||
return None
|
||||
# the dict protocol is line-based, keep the value single-line
|
||||
return json.dumps(data, separators=(",", ":"))
|
||||
|
||||
|
||||
def _is_valid_token_timestamp(timestamp, now):
|
||||
# Token if invalid after 90 days
|
||||
# or if the timestamp is in the future.
|
||||
@@ -101,6 +115,7 @@ class MetadataDictProxy(DictProxy):
|
||||
self.iroh_relay = iroh_relay
|
||||
self.turn_hostname = turn_hostname
|
||||
self.turn_socket_path = turn_socket_path
|
||||
self.appversions_path = files(__package__).joinpath("defaults/appversions.json")
|
||||
|
||||
def handle_lookup(self, parts):
|
||||
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
|
||||
@@ -125,6 +140,9 @@ class MetadataDictProxy(DictProxy):
|
||||
case "maxsmtprecipients":
|
||||
# postfix default (see "postconf smtpd_recipient_limit")
|
||||
return "O1000\n"
|
||||
case "appversions":
|
||||
value = read_appversions(self.appversions_path)
|
||||
return f"O{value}\n" if value else "N\n"
|
||||
|
||||
logging.warning(f"lookup ignored: {parts!r}")
|
||||
return "N\n"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from chatmaild.metadata import MetadataDictProxy
|
||||
|
||||
ALLOWED_URL_PREFIXES = (
|
||||
"https://github.com/deltachat/",
|
||||
"https://download.delta.chat/",
|
||||
)
|
||||
|
||||
|
||||
def check_string(value):
|
||||
assert isinstance(value, str), value
|
||||
assert value
|
||||
|
||||
|
||||
def check_version_integer(value):
|
||||
# core parses this as u32, see https://github.com/chatmail/core/pull/8557
|
||||
assert isinstance(value, int) and not isinstance(value, bool), value
|
||||
assert 0 <= value < 2**32, value
|
||||
|
||||
|
||||
def check_appversions(data):
|
||||
"""Verifies the file the way core parses it.
|
||||
|
||||
core deserializes into typed structs and drops the whole payload
|
||||
of a relay if a single value has an unexpected type,
|
||||
while missing or misspelled keys silently turn into defaults.
|
||||
"""
|
||||
assert set(data) == {"clients"}, data
|
||||
assert isinstance(data["clients"], list)
|
||||
assert data["clients"]
|
||||
client_ids = []
|
||||
for client in data["clients"]:
|
||||
assert set(client) == {"clientId", "sources"}, client
|
||||
check_string(client["clientId"])
|
||||
client_ids.append(client["clientId"])
|
||||
assert isinstance(client["sources"], list)
|
||||
assert client["sources"]
|
||||
source_ids = []
|
||||
for source in client["sources"]:
|
||||
assert set(source) == {
|
||||
"sourceId",
|
||||
"versionInteger",
|
||||
"versionString",
|
||||
"downloadUrl",
|
||||
}, source
|
||||
check_string(source["sourceId"])
|
||||
source_ids.append(source["sourceId"])
|
||||
check_version_integer(source["versionInteger"])
|
||||
check_string(source["versionString"])
|
||||
check_string(source["downloadUrl"])
|
||||
assert source["downloadUrl"].startswith(ALLOWED_URL_PREFIXES)
|
||||
# core takes the first matching source, later duplicates never surface
|
||||
assert len(set(source_ids)) == len(source_ids), source_ids
|
||||
assert len(set(client_ids)) == len(client_ids), client_ids
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def appversions():
|
||||
# check the file which chatmail-metadata actually serves
|
||||
path = MetadataDictProxy(notifier=None, metadata=None).appversions_path
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def test_appversions_schema(appversions):
|
||||
check_appversions(appversions)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, -1, 2**32, "754", 754.0, None])
|
||||
def test_version_integer_rejected(appversions, value):
|
||||
appversions["clients"][0]["sources"][0]["versionInteger"] = value
|
||||
with pytest.raises(AssertionError):
|
||||
check_appversions(appversions)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["clientId", "sources"])
|
||||
def test_misspelled_client_key_rejected(appversions, key):
|
||||
client = appversions["clients"][0]
|
||||
client[key + "s"] = client.pop(key)
|
||||
with pytest.raises(AssertionError):
|
||||
check_appversions(appversions)
|
||||
|
||||
|
||||
def test_duplicate_source_id_rejected(appversions):
|
||||
sources = appversions["clients"][0]["sources"]
|
||||
sources.append(dict(sources[0]))
|
||||
with pytest.raises(AssertionError):
|
||||
check_appversions(appversions)
|
||||
|
||||
|
||||
def test_foreign_download_url_rejected(appversions):
|
||||
appversions["clients"][0]["sources"][0]["downloadUrl"] = "https://example.org/x.apk"
|
||||
with pytest.raises(AssertionError):
|
||||
check_appversions(appversions)
|
||||
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
@@ -7,6 +8,7 @@ import requests
|
||||
from chatmaild.metadata import (
|
||||
Metadata,
|
||||
MetadataDictProxy,
|
||||
read_appversions,
|
||||
)
|
||||
from chatmaild.notifier import (
|
||||
Notifier,
|
||||
@@ -369,6 +371,32 @@ def test_iroh_relay(dictproxy):
|
||||
assert wfile.getvalue() == b"Ohttps://example.org/\n"
|
||||
|
||||
|
||||
def test_read_appversions(tmp_path):
|
||||
path = tmp_path.joinpath("appversions.json")
|
||||
assert read_appversions(path) is None
|
||||
|
||||
path.write_text('{\n "clients": []\n}')
|
||||
assert read_appversions(path) == '{"clients":[]}'
|
||||
|
||||
# the value travels as a single dict protocol line
|
||||
path.write_text('{"clients": [{"clientId": "one\\ntwo"}]}')
|
||||
assert read_appversions(path) == '{"clients":[{"clientId":"one\\ntwo"}]}'
|
||||
|
||||
path.write_text("bad json")
|
||||
assert read_appversions(path) is None
|
||||
|
||||
|
||||
def test_appversions_lookup(dictproxy):
|
||||
# the version information shipped with chatmaild is served as a single line
|
||||
key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/appversions"
|
||||
key += b"\tuser@example.org"
|
||||
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
|
||||
dictproxy.loop_forever(rfile, wfile)
|
||||
value = wfile.getvalue()
|
||||
assert value.startswith(b"O") and value.endswith(b"\n")
|
||||
assert json.loads(value[1:])["clients"]
|
||||
|
||||
|
||||
def test_legacy_token_migration(metadata, testaddr):
|
||||
with metadata.get_metadata_dict(testaddr).modify() as data:
|
||||
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
|
||||
|
||||
Reference in New Issue
Block a user