diff --git a/README.md b/README.md index 5025121..0b84a3c 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ _Non-breaking changes:_ * Bug fix: base32-encode TIDs in record keys, `at://` URIs, commit `rev`s, etc. Before, we were using the integer UNIX timestamp directly, which happened to be the same 13 character length. Oops. * Switch from `BGS_HOST` environment variable to `RELAY_HOST`. `BGS_HOST` is still supported for backward compatibility. +* `storage`: + * Add new `Storage.tombstone_repo` method, implement in `MemoryStorage` and `DatastoreStorage`. [Used to delete accounts.](https://github.com/bluesky-social/atproto/discussions/2503#discussioncomment-9502339) ([bridgy-fed#783](https://github.com/snarfed/bridgy-fed/issues/783)) * `datastore_storage`: * Bug fix for `DatastoreStorage.last_seq`, handle new NSID. * Add new `AtpRemoteBlob` class for storing "remote" blobs, available at public HTTP URLs, that we don't store ourselves. diff --git a/arroba/datastore_storage.py b/arroba/datastore_storage.py index 3accddf..c2a847f 100644 --- a/arroba/datastore_storage.py +++ b/arroba/datastore_storage.py @@ -17,7 +17,7 @@ from .repo import Repo from . import storage from .storage import Action, Block, Storage, SUBSCRIBE_REPOS_NSID -from .util import dag_cbor_cid, tid_to_int +from .util import dag_cbor_cid, tid_to_int, TOMBSTONED, TombstonedRepo logger = logging.getLogger(__name__) @@ -80,7 +80,7 @@ class CommitOp(ndb.Model): https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty """ - action = ndb.StringProperty(required=True, choices=['create', 'update', 'delete']) + action = ndb.StringProperty(required=True, choices=('create', 'update', 'delete')) path = ndb.StringProperty(required=True) cid = ndb.StringProperty() # unset for deletes @@ -96,6 +96,7 @@ class AtpRepo(ndb.Model): * head (str): CID * signing_key (str) * rotation_key (str) + * status (str) """ handles = ndb.StringProperty(repeated=True) head = ndb.StringProperty(required=True) @@ -107,6 +108,7 @@ class AtpRepo(ndb.Model): # TODO: rename this recovery_key_pem? # https://discord.com/channels/1097580399187738645/1098725036917002302/1153447354003894372 rotation_key_pem = ndb.BlobProperty() + status = ndb.StringProperty(choices=(TOMBSTONED,)) created = ndb.DateTimeProperty(auto_now_add=True) updated = ndb.DateTimeProperty(auto_now=True) @@ -411,6 +413,8 @@ def load_repo(self, did_or_handle): if not atp_repo: logger.info(f"Couldn't find repo for {did_or_handle}") return None + elif atp_repo.status == TOMBSTONED: + raise TombstonedRepo(f'{atp_repo.key} is tombstoned') logger.info(f'Loading repo {atp_repo.key}') self.head = CID.decode(atp_repo.head) @@ -420,6 +424,19 @@ def load_repo(self, did_or_handle): signing_key=atp_repo.signing_key, rotation_key=atp_repo.rotation_key) + @ndb_context + def tombstone_repo(self, repo): + @ndb.transactional() + def update(): + atp_repo = AtpRepo.get_by_id(repo.did) + atp_repo.status = TOMBSTONED + atp_repo.put() + + update() + + if repo.callback: + repo.callback(None) # TODO: format tombstone event + @ndb_context def read(self, cid): block = AtpBlock.get_by_id(cid.encode('base32')) diff --git a/arroba/repo.py b/arroba/repo.py index 44c732f..03e8e0e 100644 --- a/arroba/repo.py +++ b/arroba/repo.py @@ -76,6 +76,7 @@ class Repo: callback = None signing_key = None rotation_key = None + status = None def __init__(self, *, storage=None, mst=None, head=None, handle=None, callback=None, signing_key=None, rotation_key=None): diff --git a/arroba/storage.py b/arroba/storage.py index 1b45d36..771c12d 100644 --- a/arroba/storage.py +++ b/arroba/storage.py @@ -10,7 +10,7 @@ from multiformats import CID, multicodec, multihash from . import util -from .util import dag_cbor_cid, tid_to_int +from .util import dag_cbor_cid, tid_to_int, TOMBSTONED, TombstonedRepo SUBSCRIBE_REPOS_NSID = 'com.atproto.sync.subscribeRepos' @@ -151,6 +151,20 @@ def load_repo(self, did_or_handle): Returns: Repo, or None if the did or handle wasn't found: + + Raises: + TombstonedRepo: if the repo is tombstoned + """ + raise NotImplementedError() + + def tombstone_repo(self, repo): + """Marks a repo as tombstoned. + + After this, :meth:`load_repo` will raise :class:`TombstonedRepo` for + this repo. + + Args: + repo (Repo) """ raise NotImplementedError() @@ -329,8 +343,13 @@ def load_repo(self, did_or_handle): for repo in self.repos: if did_or_handle in (repo.did, repo.handle): + if repo.status == TOMBSTONED: + raise TombstonedRepo(f'{repo.did} is tombstoned') return repo + def tombstone_repo(self, repo): + repo.status = TOMBSTONED + def read(self, cid): return self.blocks.get(cid) diff --git a/arroba/tests/test_datastore_storage.py b/arroba/tests/test_datastore_storage.py index e8e51ac..eeca022 100644 --- a/arroba/tests/test_datastore_storage.py +++ b/arroba/tests/test_datastore_storage.py @@ -19,7 +19,7 @@ ) from ..repo import Action, Repo, Write from ..storage import Block, CommitData, MemoryStorage, SUBSCRIBE_REPOS_NSID -from ..util import dag_cbor_cid, new_key, next_tid +from ..util import dag_cbor_cid, new_key, next_tid, TOMBSTONED, TombstonedRepo from . import test_repo from .testutil import DatastoreTest, requests_response @@ -80,10 +80,19 @@ def test_create_load_repo(self): def test_create_load_repo_no_handle(self): repo = Repo.create(self.storage, 'did:web:user.com', signing_key=self.key, rotation_key=self.key) - # self.storage.create_repo(repo) self.assertEqual([], AtpRepo.get_by_id('did:web:user.com').handles) self.assertIsNone(self.storage.load_repo('han.dull')) + def test_tombstone_repo(self): + repo = Repo.create(self.storage, 'did:user', signing_key=self.key) + self.assertIsNone(AtpRepo.get_by_id('did:user').status) + + self.storage.tombstone_repo(repo) + self.assertEqual(TOMBSTONED, AtpRepo.get_by_id('did:user').status) + + with self.assertRaises(TombstonedRepo): + self.storage.load_repo('did:user') + def test_atp_block_create(self): data = {'foo': 'bar'} AtpBlock.create(repo_did='did:web:user.com', data=data, seq=1) diff --git a/arroba/tests/test_storage.py b/arroba/tests/test_storage.py index 9349512..03bc967 100644 --- a/arroba/tests/test_storage.py +++ b/arroba/tests/test_storage.py @@ -4,9 +4,9 @@ import dag_cbor from multiformats import CID -from ..util import next_tid from ..repo import Repo, Write from ..storage import Action, Block, MemoryStorage +from ..util import next_tid, TOMBSTONED, TombstonedRepo from .testutil import TestCase @@ -76,3 +76,13 @@ def test_read_commits_by_seq_include_record_block_even_if_preexisting(self): record = Block(decoded={'foo': 'bar'}) self.assertEqual(record, commits[0].blocks[record.cid]) + + def test_tombstone_repo(self): + storage = MemoryStorage() + repo = Repo.create(storage, 'did:user', signing_key=self.key) + storage.tombstone_repo(repo) + + self.assertEqual(TOMBSTONED, repo.status) + + with self.assertRaises(TombstonedRepo): + storage.load_repo('did:user') diff --git a/arroba/util.py b/arroba/util.py index ebaf1ac..996a2c6 100644 --- a/arroba/util.py +++ b/arroba/util.py @@ -40,6 +40,11 @@ ec.SECP256K1: 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_BAAEDCE6_AF48A03B_BFD25E8C_D0364141 } +TOMBSTONED = 'tombstoned' + +class TombstonedRepo(Exception): + pass + def now(tz=timezone.utc, **kwargs): """Wrapper for :meth:`datetime.datetime.now` that lets us mock it out in tests."""