diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index bd9fd87c54de936f642cafc85e535cf7d8e50871..3a54b718af0bed70dc76dbd84b5e691691f76a13 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -147,3 +147,28 @@ def order_for_search(qs, field): this function will order the given qs based on the length of the given field """ return qs.annotate(__size=models.functions.Length(field)).order_by("__size") + + +def replace_prefix(queryset, field, old, new): + """ + Given a queryset of objects and a field name, will find objects + for which the field have the given value, and replace the old prefix by + the new one. + + This is especially useful to find/update bad federation ids, to replace: + + http://wrongprotocolanddomain/path + + by + + https://goodprotocalanddomain/path + + on a whole table with a single query. + """ + qs = queryset.filter(**{"{}__startswith".format(field): old}) + # we extract the part after the old prefix, and Concat it with our new prefix + update = models.functions.Concat( + models.Value(new), + models.functions.Substr(field, len(old) + 1, output_field=models.CharField()), + ) + return qs.update(**{field: update}) diff --git a/api/funkwhale_api/federation/management/__init__.py b/api/funkwhale_api/federation/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/federation/management/commands/__init__.py b/api/funkwhale_api/federation/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/federation/management/commands/fix_federation_ids.py b/api/funkwhale_api/federation/management/commands/fix_federation_ids.py new file mode 100644 index 0000000000000000000000000000000000000000..e53eb62b43db818c6996c738f5292f4e90ba8527 --- /dev/null +++ b/api/funkwhale_api/federation/management/commands/fix_federation_ids.py @@ -0,0 +1,122 @@ +from django.core.management.base import BaseCommand, CommandError + +from funkwhale_api.common import utils +from funkwhale_api.federation import models as federation_models +from funkwhale_api.music import models as music_models + + +MODELS = [ + (music_models.Artist, ["fid"]), + (music_models.Album, ["fid"]), + (music_models.Track, ["fid"]), + (music_models.Upload, ["fid"]), + (music_models.Library, ["fid", "followers_url"]), + ( + federation_models.Actor, + [ + "fid", + "url", + "outbox_url", + "inbox_url", + "following_url", + "followers_url", + "shared_inbox_url", + ], + ), + (federation_models.Activity, ["fid"]), + (federation_models.Follow, ["fid"]), + (federation_models.LibraryFollow, ["fid"]), +] + + +class Command(BaseCommand): + help = """ + Find and replace wrong protocal/domain in local federation ids. + + Use with caution and only if you know what you are doing. + """ + + def add_arguments(self, parser): + parser.add_argument( + "old_base_url", + type=str, + help="The invalid url prefix you want to find and replace, e.g 'http://baddomain'", + ) + parser.add_argument( + "new_base_url", + type=str, + help="The url prefix you want to use in place of the bad one, e.g 'https://gooddomain'", + ) + parser.add_argument( + "--noinput", + "--no-input", + action="store_false", + dest="interactive", + help="Do NOT prompt the user for input of any kind.", + ) + + parser.add_argument( + "--no-dry-run", + action="store_false", + dest="dry_run", + help="Commit the changes to the database", + ) + + def handle(self, *args, **options): + results = {} + old_prefix, new_prefix = options["old_base_url"], options["new_base_url"] + for kls, fields in MODELS: + results[kls] = {} + for field in fields: + candidates = kls.objects.filter( + **{"{}__startswith".format(field): old_prefix} + ) + results[kls][field] = candidates.count() + + total = sum([t for k in results.values() for t in k.values()]) + self.stdout.write("") + if total: + self.stdout.write( + self.style.WARNING( + "Will replace {} found occurences of '{}' by '{}':".format( + total, old_prefix, new_prefix + ) + ) + ) + self.stdout.write("") + for kls, fields in results.items(): + for field, count in fields.items(): + self.stdout.write( + "- {}/{} {}.{}".format( + count, kls.objects.count(), kls._meta.label, field + ) + ) + + else: + self.stdout.write( + "No objects found with prefix {}, exiting.".format(old_prefix) + ) + return + if options["dry_run"]: + self.stdout.write( + "Run this command with --no-dry-run to perform the replacement." + ) + return + self.stdout.write("") + if options["interactive"]: + message = ( + "Are you sure you want to perform the replacement on {} objects?\n\n" + "Type 'yes' to continue, or 'no' to cancel: " + ).format(total) + if input("".join(message)) != "yes": + raise CommandError("Command canceled.") + + for kls, fields in results.items(): + for field, count in fields.items(): + self.stdout.write( + "Replacing {} on {} {}…".format(field, count, kls._meta.label) + ) + candidates = kls.objects.all() + utils.replace_prefix(candidates, field, old=old_prefix, new=new_prefix) + self.stdout.write("") + self.stdout.write(self.style.SUCCESS("Done!")) diff --git a/api/tests/common/test_utils.py b/api/tests/common/test_utils.py index 28d757eb56f98f70716a57b938742d4cab6f8738..c5c4a8e239b9e24f1699b3d29b36e2d0b720e0f8 100644 --- a/api/tests/common/test_utils.py +++ b/api/tests/common/test_utils.py @@ -8,3 +8,37 @@ def test_chunk_queryset(factories): assert list(chunks[0]) == actors[0:2] assert list(chunks[1]) == actors[2:4] + + +def test_update_prefix(factories): + actors = [] + fid = "http://hello.world/actor/{}/" + for i in range(3): + actors.append(factories["federation.Actor"](fid=fid.format(i))) + noop = [ + factories["federation.Actor"](fid="https://hello.world/actor/witness/"), + factories["federation.Actor"](fid="http://another.world/actor/witness/"), + factories["federation.Actor"](fid="http://foo.bar/actor/witness/"), + ] + + qs = actors[0].__class__.objects.filter(fid__startswith="http://hello.world") + assert qs.count() == 3 + + result = utils.replace_prefix( + actors[0].__class__.objects.all(), + "fid", + "http://hello.world", + "https://hello.world", + ) + + assert result == 3 + + for n in noop: + old = n.fid + n.refresh_from_db() + assert old == n.fid + + for n in actors: + old = n.fid + n.refresh_from_db() + assert n.fid == old.replace("http://", "https://") diff --git a/api/tests/federation/test_commands.py b/api/tests/federation/test_commands.py new file mode 100644 index 0000000000000000000000000000000000000000..621b8cf64da8d175a4d2ac252d6478dbd72d0863 --- /dev/null +++ b/api/tests/federation/test_commands.py @@ -0,0 +1,55 @@ +from django.core.management import call_command + +from funkwhale_api.federation import models as federation_models +from funkwhale_api.federation.management.commands import fix_federation_ids +from funkwhale_api.music import models as music_models + + +def test_fix_fids_dry_run(factories, mocker): + replace_prefix = mocker.patch("funkwhale_api.common.utils.replace_prefix") + + call_command("fix_federation_ids", "http://old/", "https://new/", interactive=False) + + replace_prefix.assert_not_called() + + +def test_fix_fids_no_dry_run(factories, mocker, queryset_equal_queries): + replace_prefix = mocker.patch("funkwhale_api.common.utils.replace_prefix") + factories["federation.Actor"](fid="http://old/test") + call_command( + "fix_federation_ids", + "http://old", + "https://new", + interactive=False, + dry_run=False, + ) + + models = [ + (music_models.Artist, ["fid"]), + (music_models.Album, ["fid"]), + (music_models.Track, ["fid"]), + (music_models.Upload, ["fid"]), + (music_models.Library, ["fid", "followers_url"]), + ( + federation_models.Actor, + [ + "fid", + "url", + "outbox_url", + "inbox_url", + "following_url", + "followers_url", + "shared_inbox_url", + ], + ), + (federation_models.Activity, ["fid"]), + (federation_models.Follow, ["fid"]), + (federation_models.LibraryFollow, ["fid"]), + ] + assert models == fix_federation_ids.MODELS + + for kls, fields in models: + for field in fields: + replace_prefix.assert_any_call( + kls.objects.all(), field, old="http://old", new="https://new" + ) diff --git a/changes/changelog.d/706.enhancement b/changes/changelog.d/706.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..7ede46b983b75cdfb92d910f865b2d9726b18198 --- /dev/null +++ b/changes/changelog.d/706.enhancement @@ -0,0 +1,2 @@ +Added a 'fix_federation_ids' management command to deal with protocol/domain issues in federation +IDs after deployments (#706)