mirror of
https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api.git
synced 2024-11-28 06:51:28 +00:00
fix: Remove unused migration entirely
This commit is contained in:
parent
fa766b7027
commit
aa4e5a9251
|
@ -8,36 +8,12 @@ at api.skippedMigrations in userdata.json and populating it
|
||||||
with IDs of the migrations to skip.
|
with IDs of the migrations to skip.
|
||||||
Adding DISABLE_ALL to that array disables the migrations module entirely.
|
Adding DISABLE_ALL to that array disables the migrations module entirely.
|
||||||
"""
|
"""
|
||||||
# from selfprivacy_api.migrations.check_for_failed_binds_migration import (
|
|
||||||
# CheckForFailedBindsMigration,
|
|
||||||
# )
|
|
||||||
from selfprivacy_api.utils import ReadUserData, UserDataFiles
|
|
||||||
|
|
||||||
# from selfprivacy_api.migrations.fix_nixos_config_branch import FixNixosConfigBranch
|
from selfprivacy_api.utils import ReadUserData, UserDataFiles
|
||||||
# from selfprivacy_api.migrations.create_tokens_json import CreateTokensJson
|
from selfprivacy_api.migrations.write_token_to_redis import WriteTokenToRedis
|
||||||
# from selfprivacy_api.migrations.migrate_to_selfprivacy_channel import (
|
|
||||||
# MigrateToSelfprivacyChannel,
|
|
||||||
# )
|
|
||||||
# from selfprivacy_api.migrations.mount_volume import MountVolume
|
|
||||||
# from selfprivacy_api.migrations.providers import CreateProviderFields
|
|
||||||
# from selfprivacy_api.migrations.prepare_for_nixos_2211 import (
|
|
||||||
# MigrateToSelfprivacyChannelFrom2205,
|
|
||||||
# )
|
|
||||||
# from selfprivacy_api.migrations.prepare_for_nixos_2305 import (
|
|
||||||
# MigrateToSelfprivacyChannelFrom2211,
|
|
||||||
# )
|
|
||||||
# from selfprivacy_api.migrations.redis_tokens import LoadTokensToRedis
|
|
||||||
|
|
||||||
migrations = [
|
migrations = [
|
||||||
# FixNixosConfigBranch(),
|
WriteTokenToRedis(),
|
||||||
# CreateTokensJson(),
|
|
||||||
# MigrateToSelfprivacyChannel(),
|
|
||||||
# MountVolume(),
|
|
||||||
# CheckForFailedBindsMigration(),
|
|
||||||
# CreateProviderFields(),
|
|
||||||
# MigrateToSelfprivacyChannelFrom2205(),
|
|
||||||
# MigrateToSelfprivacyChannelFrom2211(),
|
|
||||||
# LoadTokensToRedis(),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,48 +0,0 @@
|
||||||
from selfprivacy_api.jobs import JobStatus, Jobs
|
|
||||||
|
|
||||||
from selfprivacy_api.migrations.migration import Migration
|
|
||||||
from selfprivacy_api.utils import WriteUserData
|
|
||||||
|
|
||||||
|
|
||||||
class CheckForFailedBindsMigration(Migration):
|
|
||||||
"""Mount volume."""
|
|
||||||
|
|
||||||
def get_migration_name(self):
|
|
||||||
return "check_for_failed_binds_migration"
|
|
||||||
|
|
||||||
def get_migration_description(self):
|
|
||||||
return "If binds migration failed, try again."
|
|
||||||
|
|
||||||
def is_migration_needed(self):
|
|
||||||
try:
|
|
||||||
jobs = Jobs.get_jobs()
|
|
||||||
# If there is a job with type_id "migrations.migrate_to_binds" and status is not "FINISHED",
|
|
||||||
# then migration is needed and job is deleted
|
|
||||||
for job in jobs:
|
|
||||||
if (
|
|
||||||
job.type_id == "migrations.migrate_to_binds"
|
|
||||||
and job.status != JobStatus.FINISHED
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def migrate(self):
|
|
||||||
# Get info about existing volumes
|
|
||||||
# Write info about volumes to userdata.json
|
|
||||||
try:
|
|
||||||
jobs = Jobs.get_jobs()
|
|
||||||
for job in jobs:
|
|
||||||
if (
|
|
||||||
job.type_id == "migrations.migrate_to_binds"
|
|
||||||
and job.status != JobStatus.FINISHED
|
|
||||||
):
|
|
||||||
Jobs.remove(job)
|
|
||||||
with WriteUserData() as userdata:
|
|
||||||
userdata["useBinds"] = False
|
|
||||||
print("Done")
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
print("Error mounting volume")
|
|
|
@ -1,58 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from selfprivacy_api.migrations.migration import Migration
|
|
||||||
from selfprivacy_api.utils import TOKENS_FILE, ReadUserData
|
|
||||||
|
|
||||||
|
|
||||||
class CreateTokensJson(Migration):
|
|
||||||
def get_migration_name(self):
|
|
||||||
return "create_tokens_json"
|
|
||||||
|
|
||||||
def get_migration_description(self):
|
|
||||||
return """Selfprivacy API used a single token in userdata.json for authentication.
|
|
||||||
This migration creates a new tokens.json file with the old token in it.
|
|
||||||
This migration runs if the tokens.json file does not exist.
|
|
||||||
Old token is located at ["api"]["token"] in userdata.json.
|
|
||||||
tokens.json path is declared in TOKENS_FILE imported from utils.py
|
|
||||||
tokens.json must have the following format:
|
|
||||||
{
|
|
||||||
"tokens": [
|
|
||||||
{
|
|
||||||
"token": "token_string",
|
|
||||||
"name": "Master Token",
|
|
||||||
"date": "current date from str(datetime.now())",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
tokens.json must have 0600 permissions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def is_migration_needed(self):
|
|
||||||
return not os.path.exists(TOKENS_FILE)
|
|
||||||
|
|
||||||
def migrate(self):
|
|
||||||
try:
|
|
||||||
print(f"Creating tokens.json file at {TOKENS_FILE}")
|
|
||||||
with ReadUserData() as userdata:
|
|
||||||
token = userdata["api"]["token"]
|
|
||||||
# Touch tokens.json with 0600 permissions
|
|
||||||
Path(TOKENS_FILE).touch(mode=0o600)
|
|
||||||
# Write token to tokens.json
|
|
||||||
structure = {
|
|
||||||
"tokens": [
|
|
||||||
{
|
|
||||||
"token": token,
|
|
||||||
"name": "primary_token",
|
|
||||||
"date": str(datetime.now()),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
with open(TOKENS_FILE, "w", encoding="utf-8") as tokens:
|
|
||||||
json.dump(structure, tokens, indent=4)
|
|
||||||
print("Done")
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
print("Error creating tokens.json")
|
|
|
@ -1,57 +0,0 @@
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from selfprivacy_api.migrations.migration import Migration
|
|
||||||
|
|
||||||
|
|
||||||
class FixNixosConfigBranch(Migration):
|
|
||||||
def get_migration_name(self):
|
|
||||||
return "fix_nixos_config_branch"
|
|
||||||
|
|
||||||
def get_migration_description(self):
|
|
||||||
return """Mobile SelfPrivacy app introduced a bug in version 0.4.0.
|
|
||||||
New servers were initialized with a rolling-testing nixos config branch.
|
|
||||||
This was fixed in app version 0.4.2, but existing servers were not updated.
|
|
||||||
This migration fixes this by changing the nixos config branch to master.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def is_migration_needed(self):
|
|
||||||
"""Check the current branch of /etc/nixos and return True if it is rolling-testing"""
|
|
||||||
current_working_directory = os.getcwd()
|
|
||||||
try:
|
|
||||||
os.chdir("/etc/nixos")
|
|
||||||
nixos_config_branch = subprocess.check_output(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"], start_new_session=True
|
|
||||||
)
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
return nixos_config_branch.decode("utf-8").strip() == "rolling-testing"
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def migrate(self):
|
|
||||||
"""Affected server pulled the config with the --single-branch flag.
|
|
||||||
Git config remote.origin.fetch has to be changed, so all branches will be fetched.
|
|
||||||
Then, fetch all branches, pull and switch to master branch.
|
|
||||||
"""
|
|
||||||
print("Fixing Nixos config branch")
|
|
||||||
current_working_directory = os.getcwd()
|
|
||||||
try:
|
|
||||||
os.chdir("/etc/nixos")
|
|
||||||
|
|
||||||
subprocess.check_output(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"config",
|
|
||||||
"remote.origin.fetch",
|
|
||||||
"+refs/heads/*:refs/remotes/origin/*",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
subprocess.check_output(["git", "fetch", "--all"])
|
|
||||||
subprocess.check_output(["git", "pull"])
|
|
||||||
subprocess.check_output(["git", "checkout", "master"])
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
print("Done")
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
print("Error")
|
|
|
@ -1,49 +0,0 @@
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from selfprivacy_api.migrations.migration import Migration
|
|
||||||
|
|
||||||
|
|
||||||
class MigrateToSelfprivacyChannel(Migration):
|
|
||||||
"""Migrate to selfprivacy Nix channel."""
|
|
||||||
|
|
||||||
def get_migration_name(self):
|
|
||||||
return "migrate_to_selfprivacy_channel"
|
|
||||||
|
|
||||||
def get_migration_description(self):
|
|
||||||
return "Migrate to selfprivacy Nix channel."
|
|
||||||
|
|
||||||
def is_migration_needed(self):
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output(
|
|
||||||
["nix-channel", "--list"], start_new_session=True
|
|
||||||
)
|
|
||||||
output = output.decode("utf-8")
|
|
||||||
first_line = output.split("\n", maxsplit=1)[0]
|
|
||||||
return first_line.startswith("nixos") and (
|
|
||||||
first_line.endswith("nixos-21.11") or first_line.endswith("nixos-21.05")
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def migrate(self):
|
|
||||||
# Change the channel and update them.
|
|
||||||
# Also, go to /etc/nixos directory and make a git pull
|
|
||||||
current_working_directory = os.getcwd()
|
|
||||||
try:
|
|
||||||
print("Changing channel")
|
|
||||||
os.chdir("/etc/nixos")
|
|
||||||
subprocess.check_output(
|
|
||||||
[
|
|
||||||
"nix-channel",
|
|
||||||
"--add",
|
|
||||||
"https://channel.selfprivacy.org/nixos-selfpricacy",
|
|
||||||
"nixos",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
subprocess.check_output(["nix-channel", "--update"])
|
|
||||||
subprocess.check_output(["git", "pull"])
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
print("Error")
|
|
|
@ -1,51 +0,0 @@
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from selfprivacy_api.migrations.migration import Migration
|
|
||||||
from selfprivacy_api.utils import ReadUserData, WriteUserData
|
|
||||||
from selfprivacy_api.utils.block_devices import BlockDevices
|
|
||||||
|
|
||||||
|
|
||||||
class MountVolume(Migration):
|
|
||||||
"""Mount volume."""
|
|
||||||
|
|
||||||
def get_migration_name(self):
|
|
||||||
return "mount_volume"
|
|
||||||
|
|
||||||
def get_migration_description(self):
|
|
||||||
return "Mount volume if it is not mounted."
|
|
||||||
|
|
||||||
def is_migration_needed(self):
|
|
||||||
try:
|
|
||||||
with ReadUserData() as userdata:
|
|
||||||
return "volumes" not in userdata
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def migrate(self):
|
|
||||||
# Get info about existing volumes
|
|
||||||
# Write info about volumes to userdata.json
|
|
||||||
try:
|
|
||||||
volumes = BlockDevices().get_block_devices()
|
|
||||||
# If there is an unmounted volume sdb,
|
|
||||||
# Write it to userdata.json
|
|
||||||
is_there_a_volume = False
|
|
||||||
for volume in volumes:
|
|
||||||
if volume.name == "sdb":
|
|
||||||
is_there_a_volume = True
|
|
||||||
break
|
|
||||||
with WriteUserData() as userdata:
|
|
||||||
userdata["volumes"] = []
|
|
||||||
if is_there_a_volume:
|
|
||||||
userdata["volumes"].append(
|
|
||||||
{
|
|
||||||
"device": "/dev/sdb",
|
|
||||||
"mountPoint": "/volumes/sdb",
|
|
||||||
"fsType": "ext4",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
print("Done")
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
print("Error mounting volume")
|
|
|
@ -1,58 +0,0 @@
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from selfprivacy_api.migrations.migration import Migration
|
|
||||||
|
|
||||||
|
|
||||||
class MigrateToSelfprivacyChannelFrom2205(Migration):
|
|
||||||
"""Migrate to selfprivacy Nix channel.
|
|
||||||
For some reason NixOS 22.05 servers initialized with the nixos channel instead of selfprivacy.
|
|
||||||
This stops us from upgrading to NixOS 22.11
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_migration_name(self):
|
|
||||||
return "migrate_to_selfprivacy_channel_from_2205"
|
|
||||||
|
|
||||||
def get_migration_description(self):
|
|
||||||
return "Migrate to selfprivacy Nix channel from NixOS 22.05."
|
|
||||||
|
|
||||||
def is_migration_needed(self):
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output(
|
|
||||||
["nix-channel", "--list"], start_new_session=True
|
|
||||||
)
|
|
||||||
output = output.decode("utf-8")
|
|
||||||
first_line = output.split("\n", maxsplit=1)[0]
|
|
||||||
return first_line.startswith("nixos") and (
|
|
||||||
first_line.endswith("nixos-22.05")
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def migrate(self):
|
|
||||||
# Change the channel and update them.
|
|
||||||
# Also, go to /etc/nixos directory and make a git pull
|
|
||||||
current_working_directory = os.getcwd()
|
|
||||||
try:
|
|
||||||
print("Changing channel")
|
|
||||||
os.chdir("/etc/nixos")
|
|
||||||
subprocess.check_output(
|
|
||||||
[
|
|
||||||
"nix-channel",
|
|
||||||
"--add",
|
|
||||||
"https://channel.selfprivacy.org/nixos-selfpricacy",
|
|
||||||
"nixos",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
subprocess.check_output(["nix-channel", "--update"])
|
|
||||||
nixos_config_branch = subprocess.check_output(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"], start_new_session=True
|
|
||||||
)
|
|
||||||
if nixos_config_branch.decode("utf-8").strip() == "api-redis":
|
|
||||||
print("Also changing nixos-config branch from api-redis to master")
|
|
||||||
subprocess.check_output(["git", "checkout", "master"])
|
|
||||||
subprocess.check_output(["git", "pull"])
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
print("Error")
|
|
|
@ -1,58 +0,0 @@
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from selfprivacy_api.migrations.migration import Migration
|
|
||||||
|
|
||||||
|
|
||||||
class MigrateToSelfprivacyChannelFrom2211(Migration):
|
|
||||||
"""Migrate to selfprivacy Nix channel.
|
|
||||||
For some reason NixOS 22.11 servers initialized with the nixos channel instead of selfprivacy.
|
|
||||||
This stops us from upgrading to NixOS 23.05
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_migration_name(self):
|
|
||||||
return "migrate_to_selfprivacy_channel_from_2211"
|
|
||||||
|
|
||||||
def get_migration_description(self):
|
|
||||||
return "Migrate to selfprivacy Nix channel from NixOS 22.11."
|
|
||||||
|
|
||||||
def is_migration_needed(self):
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output(
|
|
||||||
["nix-channel", "--list"], start_new_session=True
|
|
||||||
)
|
|
||||||
output = output.decode("utf-8")
|
|
||||||
first_line = output.split("\n", maxsplit=1)[0]
|
|
||||||
return first_line.startswith("nixos") and (
|
|
||||||
first_line.endswith("nixos-22.11")
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def migrate(self):
|
|
||||||
# Change the channel and update them.
|
|
||||||
# Also, go to /etc/nixos directory and make a git pull
|
|
||||||
current_working_directory = os.getcwd()
|
|
||||||
try:
|
|
||||||
print("Changing channel")
|
|
||||||
os.chdir("/etc/nixos")
|
|
||||||
subprocess.check_output(
|
|
||||||
[
|
|
||||||
"nix-channel",
|
|
||||||
"--add",
|
|
||||||
"https://channel.selfprivacy.org/nixos-selfpricacy",
|
|
||||||
"nixos",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
subprocess.check_output(["nix-channel", "--update"])
|
|
||||||
nixos_config_branch = subprocess.check_output(
|
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"], start_new_session=True
|
|
||||||
)
|
|
||||||
if nixos_config_branch.decode("utf-8").strip() == "api-redis":
|
|
||||||
print("Also changing nixos-config branch from api-redis to master")
|
|
||||||
subprocess.check_output(["git", "checkout", "master"])
|
|
||||||
subprocess.check_output(["git", "pull"])
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
os.chdir(current_working_directory)
|
|
||||||
print("Error")
|
|
|
@ -1,43 +0,0 @@
|
||||||
from selfprivacy_api.migrations.migration import Migration
|
|
||||||
from selfprivacy_api.utils import ReadUserData, WriteUserData
|
|
||||||
|
|
||||||
|
|
||||||
class CreateProviderFields(Migration):
|
|
||||||
"""Unhardcode providers"""
|
|
||||||
|
|
||||||
def get_migration_name(self):
|
|
||||||
return "create_provider_fields"
|
|
||||||
|
|
||||||
def get_migration_description(self):
|
|
||||||
return "Add DNS, backup and server provider fields to enable user to choose between different clouds and to make the deployment adapt to these preferences."
|
|
||||||
|
|
||||||
def is_migration_needed(self):
|
|
||||||
try:
|
|
||||||
with ReadUserData() as userdata:
|
|
||||||
return "dns" not in userdata
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def migrate(self):
|
|
||||||
# Write info about providers to userdata.json
|
|
||||||
try:
|
|
||||||
with WriteUserData() as userdata:
|
|
||||||
userdata["dns"] = {
|
|
||||||
"provider": "CLOUDFLARE",
|
|
||||||
"apiKey": userdata["cloudflare"]["apiKey"],
|
|
||||||
}
|
|
||||||
userdata["server"] = {
|
|
||||||
"provider": "HETZNER",
|
|
||||||
}
|
|
||||||
userdata["backup"] = {
|
|
||||||
"provider": "BACKBLAZE",
|
|
||||||
"accountId": userdata["backblaze"]["accountId"],
|
|
||||||
"accountKey": userdata["backblaze"]["accountKey"],
|
|
||||||
"bucket": userdata["backblaze"]["bucket"],
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Done")
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
print("Error migrating provider fields")
|
|
|
@ -1,48 +0,0 @@
|
||||||
from selfprivacy_api.migrations.migration import Migration
|
|
||||||
|
|
||||||
from selfprivacy_api.repositories.tokens.json_tokens_repository import (
|
|
||||||
JsonTokensRepository,
|
|
||||||
)
|
|
||||||
from selfprivacy_api.repositories.tokens.redis_tokens_repository import (
|
|
||||||
RedisTokensRepository,
|
|
||||||
)
|
|
||||||
from selfprivacy_api.repositories.tokens.abstract_tokens_repository import (
|
|
||||||
AbstractTokensRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LoadTokensToRedis(Migration):
|
|
||||||
"""Load Json tokens into Redis"""
|
|
||||||
|
|
||||||
def get_migration_name(self):
|
|
||||||
return "load_tokens_to_redis"
|
|
||||||
|
|
||||||
def get_migration_description(self):
|
|
||||||
return "Loads access tokens and recovery keys from legacy json file into redis token storage"
|
|
||||||
|
|
||||||
def is_repo_empty(self, repo: AbstractTokensRepository) -> bool:
|
|
||||||
if repo.get_tokens() != []:
|
|
||||||
return False
|
|
||||||
if repo.get_recovery_key() is not None:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def is_migration_needed(self):
|
|
||||||
try:
|
|
||||||
if not self.is_repo_empty(JsonTokensRepository()) and self.is_repo_empty(
|
|
||||||
RedisTokensRepository()
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def migrate(self):
|
|
||||||
# Write info about providers to userdata.json
|
|
||||||
try:
|
|
||||||
RedisTokensRepository().clone(JsonTokensRepository())
|
|
||||||
|
|
||||||
print("Done")
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
print("Error migrating access tokens from json to redis")
|
|
Loading…
Reference in a new issue