mirror of
https://git.anonymousland.org/anonymousland/synapse.git
synced 2025-05-03 16:34:47 -04:00
Convert status codes to HTTPStatus
in synapse.rest.admin
(#11452)
This commit is contained in:
parent
fb58611d21
commit
e8ae94a223
12 changed files with 276 additions and 171 deletions
|
@ -79,14 +79,14 @@ class UsersRestServletV2(RestServlet):
|
|||
|
||||
if start < 0:
|
||||
raise SynapseError(
|
||||
400,
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Query parameter from must be a string representing a positive integer.",
|
||||
errcode=Codes.INVALID_PARAM,
|
||||
)
|
||||
|
||||
if limit < 0:
|
||||
raise SynapseError(
|
||||
400,
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Query parameter limit must be a string representing a positive integer.",
|
||||
errcode=Codes.INVALID_PARAM,
|
||||
)
|
||||
|
@ -122,7 +122,7 @@ class UsersRestServletV2(RestServlet):
|
|||
if (start + limit) < total:
|
||||
ret["next_token"] = str(start + len(users))
|
||||
|
||||
return 200, ret
|
||||
return HTTPStatus.OK, ret
|
||||
|
||||
|
||||
class UserRestServletV2(RestServlet):
|
||||
|
@ -172,14 +172,14 @@ class UserRestServletV2(RestServlet):
|
|||
|
||||
target_user = UserID.from_string(user_id)
|
||||
if not self.hs.is_mine(target_user):
|
||||
raise SynapseError(400, "Can only look up local users")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Can only look up local users")
|
||||
|
||||
ret = await self.admin_handler.get_user(target_user)
|
||||
|
||||
if not ret:
|
||||
raise NotFoundError("User not found")
|
||||
|
||||
return 200, ret
|
||||
return HTTPStatus.OK, ret
|
||||
|
||||
async def on_PUT(
|
||||
self, request: SynapseRequest, user_id: str
|
||||
|
@ -191,7 +191,10 @@ class UserRestServletV2(RestServlet):
|
|||
body = parse_json_object_from_request(request)
|
||||
|
||||
if not self.hs.is_mine(target_user):
|
||||
raise SynapseError(400, "This endpoint can only be used with local users")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"This endpoint can only be used with local users",
|
||||
)
|
||||
|
||||
user = await self.admin_handler.get_user(target_user)
|
||||
user_id = target_user.to_string()
|
||||
|
@ -210,7 +213,7 @@ class UserRestServletV2(RestServlet):
|
|||
|
||||
user_type = body.get("user_type", None)
|
||||
if user_type is not None and user_type not in UserTypes.ALL_USER_TYPES:
|
||||
raise SynapseError(400, "Invalid user type")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Invalid user type")
|
||||
|
||||
set_admin_to = body.get("admin", False)
|
||||
if not isinstance(set_admin_to, bool):
|
||||
|
@ -223,11 +226,13 @@ class UserRestServletV2(RestServlet):
|
|||
password = body.get("password", None)
|
||||
if password is not None:
|
||||
if not isinstance(password, str) or len(password) > 512:
|
||||
raise SynapseError(400, "Invalid password")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Invalid password")
|
||||
|
||||
deactivate = body.get("deactivated", False)
|
||||
if not isinstance(deactivate, bool):
|
||||
raise SynapseError(400, "'deactivated' parameter is not of type boolean")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "'deactivated' parameter is not of type boolean"
|
||||
)
|
||||
|
||||
# convert List[Dict[str, str]] into List[Tuple[str, str]]
|
||||
if external_ids is not None:
|
||||
|
@ -282,7 +287,9 @@ class UserRestServletV2(RestServlet):
|
|||
user_id,
|
||||
)
|
||||
except ExternalIDReuseException:
|
||||
raise SynapseError(409, "External id is already in use.")
|
||||
raise SynapseError(
|
||||
HTTPStatus.CONFLICT, "External id is already in use."
|
||||
)
|
||||
|
||||
if "avatar_url" in body and isinstance(body["avatar_url"], str):
|
||||
await self.profile_handler.set_avatar_url(
|
||||
|
@ -293,7 +300,9 @@ class UserRestServletV2(RestServlet):
|
|||
if set_admin_to != user["admin"]:
|
||||
auth_user = requester.user
|
||||
if target_user == auth_user and not set_admin_to:
|
||||
raise SynapseError(400, "You may not demote yourself.")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "You may not demote yourself."
|
||||
)
|
||||
|
||||
await self.store.set_server_admin(target_user, set_admin_to)
|
||||
|
||||
|
@ -319,7 +328,8 @@ class UserRestServletV2(RestServlet):
|
|||
and self.auth_handler.can_change_password()
|
||||
):
|
||||
raise SynapseError(
|
||||
400, "Must provide a password to re-activate an account."
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Must provide a password to re-activate an account.",
|
||||
)
|
||||
|
||||
await self.deactivate_account_handler.activate_account(
|
||||
|
@ -332,7 +342,7 @@ class UserRestServletV2(RestServlet):
|
|||
user = await self.admin_handler.get_user(target_user)
|
||||
assert user is not None
|
||||
|
||||
return 200, user
|
||||
return HTTPStatus.OK, user
|
||||
|
||||
else: # create user
|
||||
displayname = body.get("displayname", None)
|
||||
|
@ -381,7 +391,9 @@ class UserRestServletV2(RestServlet):
|
|||
user_id,
|
||||
)
|
||||
except ExternalIDReuseException:
|
||||
raise SynapseError(409, "External id is already in use.")
|
||||
raise SynapseError(
|
||||
HTTPStatus.CONFLICT, "External id is already in use."
|
||||
)
|
||||
|
||||
if "avatar_url" in body and isinstance(body["avatar_url"], str):
|
||||
await self.profile_handler.set_avatar_url(
|
||||
|
@ -429,51 +441,61 @@ class UserRegisterServlet(RestServlet):
|
|||
|
||||
nonce = secrets.token_hex(64)
|
||||
self.nonces[nonce] = int(self.reactor.seconds())
|
||||
return 200, {"nonce": nonce}
|
||||
return HTTPStatus.OK, {"nonce": nonce}
|
||||
|
||||
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
|
||||
self._clear_old_nonces()
|
||||
|
||||
if not self.hs.config.registration.registration_shared_secret:
|
||||
raise SynapseError(400, "Shared secret registration is not enabled")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "Shared secret registration is not enabled"
|
||||
)
|
||||
|
||||
body = parse_json_object_from_request(request)
|
||||
|
||||
if "nonce" not in body:
|
||||
raise SynapseError(400, "nonce must be specified", errcode=Codes.BAD_JSON)
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"nonce must be specified",
|
||||
errcode=Codes.BAD_JSON,
|
||||
)
|
||||
|
||||
nonce = body["nonce"]
|
||||
|
||||
if nonce not in self.nonces:
|
||||
raise SynapseError(400, "unrecognised nonce")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "unrecognised nonce")
|
||||
|
||||
# Delete the nonce, so it can't be reused, even if it's invalid
|
||||
del self.nonces[nonce]
|
||||
|
||||
if "username" not in body:
|
||||
raise SynapseError(
|
||||
400, "username must be specified", errcode=Codes.BAD_JSON
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"username must be specified",
|
||||
errcode=Codes.BAD_JSON,
|
||||
)
|
||||
else:
|
||||
if not isinstance(body["username"], str) or len(body["username"]) > 512:
|
||||
raise SynapseError(400, "Invalid username")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Invalid username")
|
||||
|
||||
username = body["username"].encode("utf-8")
|
||||
if b"\x00" in username:
|
||||
raise SynapseError(400, "Invalid username")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Invalid username")
|
||||
|
||||
if "password" not in body:
|
||||
raise SynapseError(
|
||||
400, "password must be specified", errcode=Codes.BAD_JSON
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"password must be specified",
|
||||
errcode=Codes.BAD_JSON,
|
||||
)
|
||||
else:
|
||||
password = body["password"]
|
||||
if not isinstance(password, str) or len(password) > 512:
|
||||
raise SynapseError(400, "Invalid password")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Invalid password")
|
||||
|
||||
password_bytes = password.encode("utf-8")
|
||||
if b"\x00" in password_bytes:
|
||||
raise SynapseError(400, "Invalid password")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Invalid password")
|
||||
|
||||
password_hash = await self.auth_handler.hash(password)
|
||||
|
||||
|
@ -482,10 +504,12 @@ class UserRegisterServlet(RestServlet):
|
|||
displayname = body.get("displayname", None)
|
||||
|
||||
if user_type is not None and user_type not in UserTypes.ALL_USER_TYPES:
|
||||
raise SynapseError(400, "Invalid user type")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Invalid user type")
|
||||
|
||||
if "mac" not in body:
|
||||
raise SynapseError(400, "mac must be specified", errcode=Codes.BAD_JSON)
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "mac must be specified", errcode=Codes.BAD_JSON
|
||||
)
|
||||
|
||||
got_mac = body["mac"]
|
||||
|
||||
|
@ -507,7 +531,7 @@ class UserRegisterServlet(RestServlet):
|
|||
want_mac = want_mac_builder.hexdigest()
|
||||
|
||||
if not hmac.compare_digest(want_mac.encode("ascii"), got_mac.encode("ascii")):
|
||||
raise SynapseError(403, "HMAC incorrect")
|
||||
raise SynapseError(HTTPStatus.FORBIDDEN, "HMAC incorrect")
|
||||
|
||||
# Reuse the parts of RegisterRestServlet to reduce code duplication
|
||||
from synapse.rest.client.register import RegisterRestServlet
|
||||
|
@ -524,7 +548,7 @@ class UserRegisterServlet(RestServlet):
|
|||
)
|
||||
|
||||
result = await register._create_registration_details(user_id, body)
|
||||
return 200, result
|
||||
return HTTPStatus.OK, result
|
||||
|
||||
|
||||
class WhoisRestServlet(RestServlet):
|
||||
|
@ -552,11 +576,11 @@ class WhoisRestServlet(RestServlet):
|
|||
await assert_user_is_admin(self.auth, auth_user)
|
||||
|
||||
if not self.hs.is_mine(target_user):
|
||||
raise SynapseError(400, "Can only whois a local user")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Can only whois a local user")
|
||||
|
||||
ret = await self.admin_handler.get_whois(target_user)
|
||||
|
||||
return 200, ret
|
||||
return HTTPStatus.OK, ret
|
||||
|
||||
|
||||
class DeactivateAccountRestServlet(RestServlet):
|
||||
|
@ -575,7 +599,9 @@ class DeactivateAccountRestServlet(RestServlet):
|
|||
await assert_user_is_admin(self.auth, requester.user)
|
||||
|
||||
if not self.is_mine(UserID.from_string(target_user_id)):
|
||||
raise SynapseError(400, "Can only deactivate local users")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "Can only deactivate local users"
|
||||
)
|
||||
|
||||
if not await self.store.get_user_by_id(target_user_id):
|
||||
raise NotFoundError("User not found")
|
||||
|
@ -597,7 +623,7 @@ class DeactivateAccountRestServlet(RestServlet):
|
|||
else:
|
||||
id_server_unbind_result = "no-support"
|
||||
|
||||
return 200, {"id_server_unbind_result": id_server_unbind_result}
|
||||
return HTTPStatus.OK, {"id_server_unbind_result": id_server_unbind_result}
|
||||
|
||||
|
||||
class AccountValidityRenewServlet(RestServlet):
|
||||
|
@ -620,7 +646,7 @@ class AccountValidityRenewServlet(RestServlet):
|
|||
|
||||
if "user_id" not in body:
|
||||
raise SynapseError(
|
||||
400,
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Missing property 'user_id' in the request body",
|
||||
)
|
||||
|
||||
|
@ -631,7 +657,7 @@ class AccountValidityRenewServlet(RestServlet):
|
|||
)
|
||||
|
||||
res = {"expiration_ts": expiration_ts}
|
||||
return 200, res
|
||||
return HTTPStatus.OK, res
|
||||
|
||||
|
||||
class ResetPasswordRestServlet(RestServlet):
|
||||
|
@ -678,7 +704,7 @@ class ResetPasswordRestServlet(RestServlet):
|
|||
await self._set_password_handler.set_password(
|
||||
target_user_id, new_password_hash, logout_devices, requester
|
||||
)
|
||||
return 200, {}
|
||||
return HTTPStatus.OK, {}
|
||||
|
||||
|
||||
class SearchUsersRestServlet(RestServlet):
|
||||
|
@ -712,16 +738,16 @@ class SearchUsersRestServlet(RestServlet):
|
|||
|
||||
# To allow all users to get the users list
|
||||
# if not is_admin and target_user != auth_user:
|
||||
# raise AuthError(403, "You are not a server admin")
|
||||
# raise AuthError(HTTPStatus.FORBIDDEN, "You are not a server admin")
|
||||
|
||||
if not self.hs.is_mine(target_user):
|
||||
raise SynapseError(400, "Can only users a local user")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Can only users a local user")
|
||||
|
||||
term = parse_string(request, "term", required=True)
|
||||
logger.info("term: %s ", term)
|
||||
|
||||
ret = await self.store.search_users(term)
|
||||
return 200, ret
|
||||
return HTTPStatus.OK, ret
|
||||
|
||||
|
||||
class UserAdminServlet(RestServlet):
|
||||
|
@ -765,11 +791,14 @@ class UserAdminServlet(RestServlet):
|
|||
target_user = UserID.from_string(user_id)
|
||||
|
||||
if not self.hs.is_mine(target_user):
|
||||
raise SynapseError(400, "Only local users can be admins of this homeserver")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Only local users can be admins of this homeserver",
|
||||
)
|
||||
|
||||
is_admin = await self.store.is_server_admin(target_user)
|
||||
|
||||
return 200, {"admin": is_admin}
|
||||
return HTTPStatus.OK, {"admin": is_admin}
|
||||
|
||||
async def on_PUT(
|
||||
self, request: SynapseRequest, user_id: str
|
||||
|
@ -785,16 +814,19 @@ class UserAdminServlet(RestServlet):
|
|||
assert_params_in_dict(body, ["admin"])
|
||||
|
||||
if not self.hs.is_mine(target_user):
|
||||
raise SynapseError(400, "Only local users can be admins of this homeserver")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Only local users can be admins of this homeserver",
|
||||
)
|
||||
|
||||
set_admin_to = bool(body["admin"])
|
||||
|
||||
if target_user == auth_user and not set_admin_to:
|
||||
raise SynapseError(400, "You may not demote yourself.")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "You may not demote yourself.")
|
||||
|
||||
await self.store.set_server_admin(target_user, set_admin_to)
|
||||
|
||||
return 200, {}
|
||||
return HTTPStatus.OK, {}
|
||||
|
||||
|
||||
class UserMembershipRestServlet(RestServlet):
|
||||
|
@ -816,7 +848,7 @@ class UserMembershipRestServlet(RestServlet):
|
|||
|
||||
room_ids = await self.store.get_rooms_for_user(user_id)
|
||||
ret = {"joined_rooms": list(room_ids), "total": len(room_ids)}
|
||||
return 200, ret
|
||||
return HTTPStatus.OK, ret
|
||||
|
||||
|
||||
class PushersRestServlet(RestServlet):
|
||||
|
@ -845,7 +877,7 @@ class PushersRestServlet(RestServlet):
|
|||
await assert_requester_is_admin(self.auth, request)
|
||||
|
||||
if not self.is_mine(UserID.from_string(user_id)):
|
||||
raise SynapseError(400, "Can only look up local users")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Can only look up local users")
|
||||
|
||||
if not await self.store.get_user_by_id(user_id):
|
||||
raise NotFoundError("User not found")
|
||||
|
@ -854,7 +886,10 @@ class PushersRestServlet(RestServlet):
|
|||
|
||||
filtered_pushers = [p.as_dict() for p in pushers]
|
||||
|
||||
return 200, {"pushers": filtered_pushers, "total": len(filtered_pushers)}
|
||||
return HTTPStatus.OK, {
|
||||
"pushers": filtered_pushers,
|
||||
"total": len(filtered_pushers),
|
||||
}
|
||||
|
||||
|
||||
class UserTokenRestServlet(RestServlet):
|
||||
|
@ -887,16 +922,22 @@ class UserTokenRestServlet(RestServlet):
|
|||
auth_user = requester.user
|
||||
|
||||
if not self.hs.is_mine_id(user_id):
|
||||
raise SynapseError(400, "Only local users can be logged in as")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "Only local users can be logged in as"
|
||||
)
|
||||
|
||||
body = parse_json_object_from_request(request, allow_empty_body=True)
|
||||
|
||||
valid_until_ms = body.get("valid_until_ms")
|
||||
if valid_until_ms and not isinstance(valid_until_ms, int):
|
||||
raise SynapseError(400, "'valid_until_ms' parameter must be an int")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "'valid_until_ms' parameter must be an int"
|
||||
)
|
||||
|
||||
if auth_user.to_string() == user_id:
|
||||
raise SynapseError(400, "Cannot use admin API to login as self")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "Cannot use admin API to login as self"
|
||||
)
|
||||
|
||||
token = await self.auth_handler.create_access_token_for_user_id(
|
||||
user_id=auth_user.to_string(),
|
||||
|
@ -905,7 +946,7 @@ class UserTokenRestServlet(RestServlet):
|
|||
puppets_user_id=user_id,
|
||||
)
|
||||
|
||||
return 200, {"access_token": token}
|
||||
return HTTPStatus.OK, {"access_token": token}
|
||||
|
||||
|
||||
class ShadowBanRestServlet(RestServlet):
|
||||
|
@ -947,11 +988,13 @@ class ShadowBanRestServlet(RestServlet):
|
|||
await assert_requester_is_admin(self.auth, request)
|
||||
|
||||
if not self.hs.is_mine_id(user_id):
|
||||
raise SynapseError(400, "Only local users can be shadow-banned")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "Only local users can be shadow-banned"
|
||||
)
|
||||
|
||||
await self.store.set_shadow_banned(UserID.from_string(user_id), True)
|
||||
|
||||
return 200, {}
|
||||
return HTTPStatus.OK, {}
|
||||
|
||||
async def on_DELETE(
|
||||
self, request: SynapseRequest, user_id: str
|
||||
|
@ -959,11 +1002,13 @@ class ShadowBanRestServlet(RestServlet):
|
|||
await assert_requester_is_admin(self.auth, request)
|
||||
|
||||
if not self.hs.is_mine_id(user_id):
|
||||
raise SynapseError(400, "Only local users can be shadow-banned")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "Only local users can be shadow-banned"
|
||||
)
|
||||
|
||||
await self.store.set_shadow_banned(UserID.from_string(user_id), False)
|
||||
|
||||
return 200, {}
|
||||
return HTTPStatus.OK, {}
|
||||
|
||||
|
||||
class RateLimitRestServlet(RestServlet):
|
||||
|
@ -995,7 +1040,7 @@ class RateLimitRestServlet(RestServlet):
|
|||
await assert_requester_is_admin(self.auth, request)
|
||||
|
||||
if not self.hs.is_mine_id(user_id):
|
||||
raise SynapseError(400, "Can only look up local users")
|
||||
raise SynapseError(HTTPStatus.BAD_REQUEST, "Can only look up local users")
|
||||
|
||||
if not await self.store.get_user_by_id(user_id):
|
||||
raise NotFoundError("User not found")
|
||||
|
@ -1016,7 +1061,7 @@ class RateLimitRestServlet(RestServlet):
|
|||
else:
|
||||
ret = {}
|
||||
|
||||
return 200, ret
|
||||
return HTTPStatus.OK, ret
|
||||
|
||||
async def on_POST(
|
||||
self, request: SynapseRequest, user_id: str
|
||||
|
@ -1024,7 +1069,9 @@ class RateLimitRestServlet(RestServlet):
|
|||
await assert_requester_is_admin(self.auth, request)
|
||||
|
||||
if not self.hs.is_mine_id(user_id):
|
||||
raise SynapseError(400, "Only local users can be ratelimited")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "Only local users can be ratelimited"
|
||||
)
|
||||
|
||||
if not await self.store.get_user_by_id(user_id):
|
||||
raise NotFoundError("User not found")
|
||||
|
@ -1036,14 +1083,14 @@ class RateLimitRestServlet(RestServlet):
|
|||
|
||||
if not isinstance(messages_per_second, int) or messages_per_second < 0:
|
||||
raise SynapseError(
|
||||
400,
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"%r parameter must be a positive int" % (messages_per_second,),
|
||||
errcode=Codes.INVALID_PARAM,
|
||||
)
|
||||
|
||||
if not isinstance(burst_count, int) or burst_count < 0:
|
||||
raise SynapseError(
|
||||
400,
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"%r parameter must be a positive int" % (burst_count,),
|
||||
errcode=Codes.INVALID_PARAM,
|
||||
)
|
||||
|
@ -1059,7 +1106,7 @@ class RateLimitRestServlet(RestServlet):
|
|||
"burst_count": ratelimit.burst_count,
|
||||
}
|
||||
|
||||
return 200, ret
|
||||
return HTTPStatus.OK, ret
|
||||
|
||||
async def on_DELETE(
|
||||
self, request: SynapseRequest, user_id: str
|
||||
|
@ -1067,11 +1114,13 @@ class RateLimitRestServlet(RestServlet):
|
|||
await assert_requester_is_admin(self.auth, request)
|
||||
|
||||
if not self.hs.is_mine_id(user_id):
|
||||
raise SynapseError(400, "Only local users can be ratelimited")
|
||||
raise SynapseError(
|
||||
HTTPStatus.BAD_REQUEST, "Only local users can be ratelimited"
|
||||
)
|
||||
|
||||
if not await self.store.get_user_by_id(user_id):
|
||||
raise NotFoundError("User not found")
|
||||
|
||||
await self.store.delete_ratelimit_for_user(user_id)
|
||||
|
||||
return 200, {}
|
||||
return HTTPStatus.OK, {}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue