mirror of https://gitlab.com/litecord/litecord.git
Merge branch 'admin-api-more-endpoints' into 'master'
Admin API improvements Closes #44 See merge request litecord/litecord!33
This commit is contained in:
commit
5dbf8ac8fd
|
|
@ -48,6 +48,26 @@ Output:
|
|||
| old | user object | old user object pre-delete |
|
||||
| new | user object | new user object post-delete |
|
||||
|
||||
### PATCH `/users/<user_id>`
|
||||
|
||||
Update a single user's information.
|
||||
|
||||
Returns a user object on success.
|
||||
|
||||
**Note:** You can not change any user's staff badge state (neither adding
|
||||
it or removing it) to not cause privilege escalation/de-escalation (where
|
||||
a staff makes more staff or a staff removes staff privileges of someone else).
|
||||
Keep in mind the staff badge is what grants access to the Admin API, so.
|
||||
|
||||
**Note:** Changing a user's nitro badge is not defined via the flags.
|
||||
Plus that would require adding an interface to user payments
|
||||
through the Admin API.
|
||||
|
||||
[UserFlags]: https://discordapp.com/developers/docs/resources/user#user-object-user-flags
|
||||
|
||||
| field | type | description |
|
||||
| --: | :-- | :-- |
|
||||
| flags | [UserFlags] | user flags/badges |
|
||||
|
||||
## Instance invites
|
||||
|
||||
|
|
@ -130,7 +150,22 @@ Returns empty body with 204 status code on success.
|
|||
|
||||
### GET `/guilds/<guild_id>`
|
||||
|
||||
Returns a partial guild object.
|
||||
Returns a partial guild object. Gives a 404 when the guild is not found.
|
||||
|
||||
### PATCH `/guilds/<guild_id>`
|
||||
|
||||
Update a single guild.
|
||||
Dispatches `GUILD_UPDATE` to subscribers of the guild.
|
||||
|
||||
Returns a guild object or an unavailable guild object on success.
|
||||
|
||||
| field | type | description |
|
||||
| --: | :-- | :-- |
|
||||
| unavailable | bool | if the guild is unavailable |
|
||||
|
||||
### DELETE `/guilds/<guild_id>`
|
||||
|
||||
Delete a single guild. Returns 204 on success.
|
||||
|
||||
## Guild features
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
|
||||
"""
|
||||
|
||||
from litecord.enums import Feature
|
||||
from litecord.enums import Feature, UserFlags
|
||||
|
||||
VOICE_SERVER = {
|
||||
'hostname': {'type': 'string', 'maxlength': 255, 'required': True}
|
||||
|
|
@ -50,3 +50,11 @@ USER_CREATE = {
|
|||
INSTANCE_INVITE = {
|
||||
'max_uses': {'type': 'integer', 'required': True}
|
||||
}
|
||||
|
||||
GUILD_UPDATE = {
|
||||
'unavailable': {'type': 'boolean', 'required': False}
|
||||
}
|
||||
|
||||
USER_UPDATE = {
|
||||
'flags': {'required': False, 'coerce': UserFlags.from_int}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
|
||||
"""
|
||||
|
||||
from quart import Blueprint, jsonify, current_app as app
|
||||
from quart import Blueprint, jsonify, current_app as app, request
|
||||
|
||||
from litecord.auth import admin_check
|
||||
from litecord.schemas import validate
|
||||
from litecord.admin_schemas import GUILD_UPDATE
|
||||
from litecord.blueprints.guilds import delete_guild
|
||||
from litecord.errors import GuildNotFound
|
||||
|
||||
bp = Blueprint('guilds_admin', __name__)
|
||||
|
||||
|
|
@ -28,6 +32,47 @@ async def get_guild(guild_id: int):
|
|||
"""Get a basic guild payload."""
|
||||
await admin_check()
|
||||
|
||||
return jsonify(
|
||||
await app.storage.get_guild(guild_id)
|
||||
)
|
||||
guild = await app.storage.get_guild(guild_id)
|
||||
|
||||
if not guild:
|
||||
raise GuildNotFound()
|
||||
|
||||
return jsonify(guild)
|
||||
|
||||
|
||||
@bp.route('/<int:guild_id>', methods=['PATCH'])
|
||||
async def update_guild(guild_id: int):
|
||||
await admin_check()
|
||||
|
||||
j = validate(await request.get_json(), GUILD_UPDATE)
|
||||
|
||||
# TODO: what happens to the other guild attributes when its
|
||||
# unavailable? do they vanish?
|
||||
old_unavailable = app.guild_store.get(guild_id, 'unavailable')
|
||||
new_unavailable = j.get('unavailable', old_unavailable)
|
||||
|
||||
# always set unavailable status since new_unavailable will be
|
||||
# old_unavailable when not provided, so we don't need to check if
|
||||
# j.unavailable is there
|
||||
app.guild_store.set(guild_id, 'unavailable', j['unavailable'])
|
||||
|
||||
guild = await app.storage.get_guild(guild_id)
|
||||
|
||||
# TODO: maybe we can just check guild['unavailable']...?
|
||||
|
||||
if old_unavailable and not new_unavailable:
|
||||
# guild became available
|
||||
await app.dispatcher.dispatch_guild(guild_id, 'GUILD_CREATE', guild)
|
||||
else:
|
||||
# guild became unavailable
|
||||
await app.dispatcher.dispatch_guild(guild_id, 'GUILD_DELETE', guild)
|
||||
|
||||
return jsonify(guild)
|
||||
|
||||
|
||||
@bp.route('/<int:guild_id>', methods=['DELETE'])
|
||||
async def delete_guild_as_admin(guild_id):
|
||||
"""Delete a single guild via the admin API without ownership checks."""
|
||||
await admin_check()
|
||||
await delete_guild(guild_id)
|
||||
return '', 204
|
||||
|
|
|
|||
|
|
@ -22,10 +22,13 @@ from quart import Blueprint, jsonify, current_app as app, request
|
|||
from litecord.auth import admin_check
|
||||
from litecord.blueprints.auth import create_user
|
||||
from litecord.schemas import validate
|
||||
from litecord.admin_schemas import USER_CREATE
|
||||
from litecord.errors import BadRequest
|
||||
from litecord.admin_schemas import USER_CREATE, USER_UPDATE
|
||||
from litecord.errors import BadRequest, Forbidden
|
||||
from litecord.utils import async_map
|
||||
from litecord.blueprints.users import delete_user, user_disconnect
|
||||
from litecord.blueprints.users import (
|
||||
delete_user, user_disconnect, mass_user_update
|
||||
)
|
||||
from litecord.enums import UserFlags
|
||||
|
||||
bp = Blueprint('users_admin', __name__)
|
||||
|
||||
|
|
@ -116,3 +119,31 @@ async def _delete_single_user(user_id: int):
|
|||
'old': old_user,
|
||||
'new': new_user
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/<int:user_id>', methods=['PATCH'])
|
||||
async def patch_user(user_id: int):
|
||||
await admin_check()
|
||||
|
||||
j = validate(await request.get_json(), USER_UPDATE)
|
||||
|
||||
# get the original user for flags checking
|
||||
user = await app.storage.get_user(user_id)
|
||||
old_flags = UserFlags.from_int(user['flags'])
|
||||
|
||||
# j.flags is already a UserFlags since we coerce it.
|
||||
if 'flags' in j:
|
||||
new_flags = j['flags']
|
||||
|
||||
# disallow any changes to the staff badge
|
||||
if new_flags.is_staff != old_flags.is_staff:
|
||||
raise Forbidden('you can not change a users staff badge')
|
||||
|
||||
await app.db.execute("""
|
||||
UPDATE users
|
||||
SET flags = $1
|
||||
WHERE id = $2
|
||||
""", new_flags.value, user_id)
|
||||
|
||||
public_user, _ = await mass_user_update(user_id, app)
|
||||
return jsonify(public_user)
|
||||
|
|
|
|||
|
|
@ -350,21 +350,17 @@ async def _update_guild(guild_id):
|
|||
return jsonify(guild)
|
||||
|
||||
|
||||
@bp.route('/<int:guild_id>', methods=['DELETE'])
|
||||
# this endpoint is not documented, but used by the official client.
|
||||
@bp.route('/<int:guild_id>/delete', methods=['POST'])
|
||||
async def delete_guild(guild_id):
|
||||
"""Delete a guild."""
|
||||
user_id = await token_check()
|
||||
await guild_owner_check(user_id, guild_id)
|
||||
async def delete_guild(guild_id: int, *, app_=None):
|
||||
"""Delete a single guild."""
|
||||
app_ = app_ or app
|
||||
|
||||
await app.db.execute("""
|
||||
await app_.db.execute("""
|
||||
DELETE FROM guilds
|
||||
WHERE guilds.id = $1
|
||||
""", guild_id)
|
||||
|
||||
# Discord's client expects IDs being string
|
||||
await app.dispatcher.dispatch('guild', guild_id, 'GUILD_DELETE', {
|
||||
await app_.dispatcher.dispatch('guild', guild_id, 'GUILD_DELETE', {
|
||||
'guild_id': str(guild_id),
|
||||
'id': str(guild_id),
|
||||
# 'unavailable': False,
|
||||
|
|
@ -373,8 +369,17 @@ async def delete_guild(guild_id):
|
|||
# remove from the dispatcher so nobody
|
||||
# becomes the little memer that tries to fuck up with
|
||||
# everybody's gateway
|
||||
await app.dispatcher.remove('guild', guild_id)
|
||||
await app_.dispatcher.remove('guild', guild_id)
|
||||
|
||||
|
||||
@bp.route('/<int:guild_id>', methods=['DELETE'])
|
||||
# this endpoint is not documented, but used by the official client.
|
||||
@bp.route('/<int:guild_id>/delete', methods=['POST'])
|
||||
async def delete_guild_handler(guild_id):
|
||||
"""Delete a guild."""
|
||||
user_id = await token_check()
|
||||
await guild_owner_check(user_id, guild_id)
|
||||
await delete_guild(guild_id)
|
||||
return '', 204
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ async def mass_user_update(user_id, app_=None):
|
|||
'lazy_guild', guild_ids, 'update_user', user_id
|
||||
)
|
||||
|
||||
return private_user
|
||||
return public_user, private_user
|
||||
|
||||
|
||||
@bp.route('/@me', methods=['GET'])
|
||||
|
|
@ -257,7 +257,7 @@ async def patch_me():
|
|||
|
||||
user.pop('password_hash')
|
||||
|
||||
private_user = await mass_user_update(user_id, app)
|
||||
_, private_user = await mass_user_update(user_id, app)
|
||||
return jsonify(private_user)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ class Flags:
|
|||
def _make_int(value):
|
||||
res = Flags()
|
||||
|
||||
setattr(res, 'value', value)
|
||||
|
||||
for attr, val in attrs:
|
||||
# get only the ones that represent a field in the
|
||||
# number's bits
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
"""
|
||||
|
||||
Litecord
|
||||
Copyright (C) 2018-2019 Luna Mendes
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, version 3 of the License.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
|
||||
class GuildMemoryStore:
|
||||
"""Store in-memory properties about guilds.
|
||||
|
||||
I could have just used Redis... probably too overkill to add
|
||||
aioredis to the already long depedency list, plus, I don't need
|
||||
"""
|
||||
def __init__(self):
|
||||
self._store = {}
|
||||
|
||||
def get(self, guild_id: int, attribute: str, default=None):
|
||||
"""get a key"""
|
||||
return self._store.get(f'{guild_id}:{attribute}', default)
|
||||
|
||||
def set(self, guild_id: int, attribute: str, value):
|
||||
"""set a key"""
|
||||
self._store[f'{guild_id}:{attribute}'] = value
|
||||
|
|
@ -180,6 +180,18 @@ class Storage:
|
|||
|
||||
drow = dict(row)
|
||||
|
||||
# a guild's unavailable state is kept in memory, and we remove every
|
||||
# other guild related field when its unavailable.
|
||||
drow['unavailable'] = self.app.guild_store.get(
|
||||
guild_id, 'unavailable', False)
|
||||
|
||||
if drow['unavailable']:
|
||||
drow = {
|
||||
'id': drow['id'],
|
||||
'unavailable': True
|
||||
}
|
||||
|
||||
# guild.owner is dependant of the user doing the get_guild call.
|
||||
if user_id:
|
||||
drow['owner'] = drow['owner_id'] == str(user_id)
|
||||
|
||||
|
|
@ -663,6 +675,9 @@ class Storage:
|
|||
if guild is None:
|
||||
return None
|
||||
|
||||
if guild['unavailable']:
|
||||
return guild
|
||||
|
||||
extra = await self.get_guild_extra(guild_id, user_id, large_count)
|
||||
|
||||
return {**guild, **extra}
|
||||
|
|
|
|||
2
run.py
2
run.py
|
|
@ -75,6 +75,7 @@ from litecord.presence import PresenceManager
|
|||
from litecord.images import IconManager
|
||||
from litecord.jobs import JobManager
|
||||
from litecord.voice.manager import VoiceManager
|
||||
from litecord.guild_memory_store import GuildMemoryStore
|
||||
|
||||
from litecord.gateway.gateway import websocket_handler
|
||||
|
||||
|
|
@ -246,6 +247,7 @@ def init_app_managers(app_):
|
|||
app_.storage.presence = app_.presence
|
||||
|
||||
app_.voice = VoiceManager(app_)
|
||||
app_.guild_store = GuildMemoryStore()
|
||||
|
||||
|
||||
async def api_index(app_):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
"""
|
||||
|
||||
Litecord
|
||||
Copyright (C) 2018-2019 Luna Mendes
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, version 3 of the License.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.common import login
|
||||
from litecord.blueprints.guilds import delete_guild
|
||||
from litecord.errors import GuildNotFound
|
||||
|
||||
async def _create_guild(test_cli, *, token=None):
|
||||
token = token or await login('admin', test_cli)
|
||||
|
||||
genned_name = secrets.token_hex(6)
|
||||
|
||||
resp = await test_cli.post('/api/v6/guilds', headers={
|
||||
'Authorization': token
|
||||
}, json={
|
||||
'name': genned_name,
|
||||
'region': None
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
rjson = await resp.json
|
||||
assert isinstance(rjson, dict)
|
||||
assert rjson['name'] == genned_name
|
||||
|
||||
return rjson
|
||||
|
||||
|
||||
async def _fetch_guild(test_cli, guild_id, *, token=None, ret_early=False):
|
||||
token = token or await login('admin', test_cli)
|
||||
|
||||
resp = await test_cli.get(f'/api/v6/admin/guilds/{guild_id}', headers={
|
||||
'Authorization': token
|
||||
})
|
||||
|
||||
if ret_early:
|
||||
return resp
|
||||
|
||||
assert resp.status_code == 200
|
||||
rjson = await resp.json
|
||||
assert isinstance(rjson, dict)
|
||||
assert rjson['id'] == guild_id
|
||||
|
||||
return rjson
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guild_fetch(test_cli):
|
||||
"""Test the creation and fetching of a guild via the Admin API."""
|
||||
token = await login('admin', test_cli)
|
||||
rjson = await _create_guild(test_cli, token=token)
|
||||
guild_id = rjson['id']
|
||||
|
||||
try:
|
||||
await _fetch_guild(test_cli, guild_id)
|
||||
finally:
|
||||
await delete_guild(int(guild_id), app_=test_cli.app)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guild_update(test_cli):
|
||||
"""Test the update of a guild via the Admin API."""
|
||||
token = await login('admin', test_cli)
|
||||
rjson = await _create_guild(test_cli, token=token)
|
||||
guild_id = rjson['id']
|
||||
assert not rjson['unavailable']
|
||||
|
||||
try:
|
||||
# I believe setting up an entire gateway client registered to the guild
|
||||
# would be overkill to test the side-effects, so... I'm not
|
||||
# testing them. Yes, I know its a bad idea, but if someone has an easier
|
||||
# way to write that, do send an MR.
|
||||
resp = await test_cli.patch(
|
||||
f'/api/v6/admin/guilds/{guild_id}',
|
||||
headers={
|
||||
'Authorization': token
|
||||
}, json={
|
||||
'unavailable': True
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
rjson = await resp.json
|
||||
assert isinstance(rjson, dict)
|
||||
assert rjson['id'] == guild_id
|
||||
assert rjson['unavailable']
|
||||
|
||||
rjson = await _fetch_guild(test_cli, guild_id, token=token)
|
||||
assert rjson['unavailable']
|
||||
finally:
|
||||
await delete_guild(int(guild_id), app_=test_cli.app)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guild_delete(test_cli):
|
||||
"""Test the update of a guild via the Admin API."""
|
||||
token = await login('admin', test_cli)
|
||||
rjson = await _create_guild(test_cli, token=token)
|
||||
guild_id = rjson['id']
|
||||
|
||||
try:
|
||||
resp = await test_cli.delete(
|
||||
f'/api/v6/admin/guilds/{guild_id}',
|
||||
headers={
|
||||
'Authorization': token
|
||||
})
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
resp = await _fetch_guild(test_cli, guild_id, token=token,
|
||||
ret_early=True)
|
||||
|
||||
assert resp.status_code == 404
|
||||
rjson = await resp.json
|
||||
assert isinstance(rjson, dict)
|
||||
assert rjson['error']
|
||||
assert rjson['code'] == GuildNotFound.error_code
|
||||
finally:
|
||||
await delete_guild(int(guild_id), app_=test_cli.app)
|
||||
|
|
@ -22,10 +22,12 @@ import secrets
|
|||
import pytest
|
||||
|
||||
from tests.common import login
|
||||
from tests.credentials import CREDS
|
||||
from litecord.enums import UserFlags
|
||||
|
||||
|
||||
async def _search(test_cli, *, username='', discrim='', token=None):
|
||||
if token is None:
|
||||
token = await login('admin', test_cli)
|
||||
token = token or await login('admin', test_cli)
|
||||
|
||||
query_string = {
|
||||
'username': username,
|
||||
|
|
@ -40,8 +42,7 @@ async def _search(test_cli, *, username='', discrim='', token=None):
|
|||
@pytest.mark.asyncio
|
||||
async def test_list_users(test_cli):
|
||||
"""Try to list as many users as possible."""
|
||||
# NOTE: replace here if admin username changes
|
||||
resp = await _search(test_cli, username='big_girl')
|
||||
resp = await _search(test_cli, username=CREDS['admin']['username'])
|
||||
|
||||
assert resp.status_code == 200
|
||||
rjson = await resp.json
|
||||
|
|
@ -49,11 +50,8 @@ async def test_list_users(test_cli):
|
|||
assert rjson
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_delete(test_cli):
|
||||
"""Create a user. Then delete them."""
|
||||
token = await login('admin', test_cli)
|
||||
|
||||
async def _setup_user(test_cli, *, token=None) -> dict:
|
||||
token = token or await login('admin', test_cli)
|
||||
genned = secrets.token_hex(7)
|
||||
|
||||
resp = await test_cli.post('/api/v6/admin/users', headers={
|
||||
|
|
@ -69,23 +67,73 @@ async def test_create_delete(test_cli):
|
|||
assert isinstance(rjson, dict)
|
||||
assert rjson['username'] == genned
|
||||
|
||||
genned_uid = rjson['id']
|
||||
return rjson
|
||||
|
||||
# check if side-effects went through with a search
|
||||
resp = await _search(test_cli, username=genned, token=token)
|
||||
|
||||
assert resp.status_code == 200
|
||||
rjson = await resp.json
|
||||
assert isinstance(rjson, list)
|
||||
assert rjson[0]['id'] == genned_uid
|
||||
async def _del_user(test_cli, user_id, *, token=None):
|
||||
"""Delete a user."""
|
||||
token = token or await login('admin', test_cli)
|
||||
|
||||
# delete
|
||||
resp = await test_cli.delete(f'/api/v6/admin/users/{genned_uid}', headers={
|
||||
resp = await test_cli.delete(f'/api/v6/admin/users/{user_id}', headers={
|
||||
'Authorization': token
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
rjson = await resp.json
|
||||
assert isinstance(rjson, dict)
|
||||
assert rjson['new']['id'] == genned_uid
|
||||
assert rjson['new']['id'] == user_id
|
||||
assert rjson['old']['id'] == rjson['new']['id']
|
||||
|
||||
# TODO: remove from database at this point? it'll just keep being
|
||||
# filled up every time we run a test..
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_delete(test_cli):
|
||||
"""Create a user. Then delete them."""
|
||||
token = await login('admin', test_cli)
|
||||
|
||||
rjson = await _setup_user(test_cli, token=token)
|
||||
|
||||
genned = rjson['username']
|
||||
genned_uid = rjson['id']
|
||||
|
||||
try:
|
||||
# check if side-effects went through with a search
|
||||
resp = await _search(test_cli, username=genned, token=token)
|
||||
|
||||
assert resp.status_code == 200
|
||||
rjson = await resp.json
|
||||
assert isinstance(rjson, list)
|
||||
assert rjson[0]['id'] == genned_uid
|
||||
finally:
|
||||
await _del_user(test_cli, genned_uid, token=token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_update(test_cli):
|
||||
"""Test user update."""
|
||||
token = await login('admin', test_cli)
|
||||
rjson = await _setup_user(test_cli, token=token)
|
||||
|
||||
user_id = rjson['id']
|
||||
|
||||
# test update
|
||||
|
||||
try:
|
||||
# set them as partner flag
|
||||
resp = await test_cli.patch(f'/api/v6/admin/users/{user_id}', headers={
|
||||
'Authorization': token
|
||||
}, json={
|
||||
'flags': UserFlags.partner,
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
rjson = await resp.json
|
||||
assert rjson['id'] == user_id
|
||||
assert rjson['flags'] == UserFlags.partner
|
||||
|
||||
# TODO: maybe we can check for side effects by fetching the
|
||||
# user manually too...
|
||||
finally:
|
||||
await _del_user(test_cli, user_id, token=token)
|
||||
|
|
|
|||
Loading…
Reference in New Issue