bug fixes
This commit is contained in:
parent
4267e0d864
commit
5958bcdb17
5 changed files with 317 additions and 6 deletions
|
|
@ -870,6 +870,33 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
|
||||||
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupLeave(id, payload.string1), password));
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupLeave(id, payload.string1), password));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "group/kick":
|
||||||
|
{
|
||||||
|
Universal2String payload = JsonSerializer.Deserialize(
|
||||||
|
serializedBody.string2,
|
||||||
|
AppJsonSerializerContext.Default.Universal2String
|
||||||
|
);
|
||||||
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupKickMember(id, payload.string1, payload.string2), password));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "group/transferowner":
|
||||||
|
{
|
||||||
|
Universal2String payload = JsonSerializer.Deserialize(
|
||||||
|
serializedBody.string2,
|
||||||
|
AppJsonSerializerContext.Default.Universal2String
|
||||||
|
);
|
||||||
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupTransferOwner(id, payload.string1, payload.string2), password));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "room/role":
|
||||||
|
{
|
||||||
|
Universal3String payload = JsonSerializer.Deserialize(
|
||||||
|
serializedBody.string2,
|
||||||
|
AppJsonSerializerContext.Default.Universal3String
|
||||||
|
);
|
||||||
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Shared.GetUserRole(id, payload.string1, payload.string2, payload.string3), password));
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "group/members":
|
case "group/members":
|
||||||
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupMembers(id, serializedBody.string2), password));
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupMembers(id, serializedBody.string2), password));
|
||||||
break;
|
break;
|
||||||
|
|
@ -1117,7 +1144,20 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] bodyBytes = await LarpixServer.Utils.Utils.LoadBodyBytes(context.Request.Body, LarpixServer.Utils.Utils.PUBLIC_STORAGE_LIMIT);
|
byte[] bodyBytes = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bodyBytes = await LarpixServer.Utils.Utils.LoadBodyBytes(context.Request.Body, LarpixServer.Utils.Utils.PUBLIC_STORAGE_LIMIT);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (ex.Message.StartsWith("error:"))
|
||||||
|
{
|
||||||
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(ex.Message, password));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
byte[] fileBytes = await Utils.NonceDecryptBytes(id, password, bodyBytes, true);
|
byte[] fileBytes = await Utils.NonceDecryptBytes(id, password, bodyBytes, true);
|
||||||
|
|
||||||
if (fileBytes == null)
|
if (fileBytes == null)
|
||||||
|
|
@ -1162,7 +1202,20 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] fileBytes = await LoadBodyBytes(context.Request.Body, ATTACHMENT_SIZE_LIMIT); // attachment size limit
|
byte[] fileBytes = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fileBytes = await LoadBodyBytes(context.Request.Body, ATTACHMENT_SIZE_LIMIT); // attachment size limit
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (ex.Message.StartsWith("error:"))
|
||||||
|
{
|
||||||
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(ex.Message, password));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
if (fileBytes == null || fileBytes.Length == 0)
|
if (fileBytes == null || fileBytes.Length == 0)
|
||||||
{
|
{
|
||||||
await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:empty.body", password));
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:empty.body", password));
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ public class Groups
|
||||||
|
|
||||||
// body is the key encrypted for the creator
|
// body is the key encrypted for the creator
|
||||||
await Fs.WriteFile($"{groupPath}/keys/0/{id};{DOMAIN}", Encoding.UTF8.GetBytes(body));
|
await Fs.WriteFile($"{groupPath}/keys/0/{id};{DOMAIN}", Encoding.UTF8.GetBytes(body));
|
||||||
|
await Fs.WriteFile($"{groupPath}/owner", Encoding.UTF8.GetBytes($"{id};{DOMAIN}"));
|
||||||
|
|
||||||
Message startingMessage = new();
|
Message startingMessage = new();
|
||||||
startingMessage.attachment = "";
|
startingMessage.attachment = "";
|
||||||
|
|
@ -171,6 +172,18 @@ public class Groups
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(historyVisible))
|
if (!string.IsNullOrEmpty(historyVisible))
|
||||||
{
|
{
|
||||||
|
string ownerPath = $"{groupPath}/owner";
|
||||||
|
string currentOwner = "";
|
||||||
|
if (Fs.Exists(ownerPath))
|
||||||
|
{
|
||||||
|
currentOwner = Encoding.UTF8.GetString(await Fs.ReadFile(ownerPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentOwner != $"{userId};{Utils.Utils.DOMAIN}")
|
||||||
|
{
|
||||||
|
return "error:forbidden";
|
||||||
|
}
|
||||||
|
|
||||||
bool currentState = false;
|
bool currentState = false;
|
||||||
if (Fs.Exists($"{groupPath}/history_visible"))
|
if (Fs.Exists($"{groupPath}/history_visible"))
|
||||||
{
|
{
|
||||||
|
|
@ -286,17 +299,24 @@ public class Groups
|
||||||
historyVisible = Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/history_visible"));
|
historyVisible = Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/history_visible"));
|
||||||
}
|
}
|
||||||
|
|
||||||
var info = new Universal6String
|
string owner = "";
|
||||||
|
if (Fs.Exists($"{groupPath}/owner"))
|
||||||
|
{
|
||||||
|
owner = Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/owner"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = new Universal7String
|
||||||
{
|
{
|
||||||
string1 = groupId,
|
string1 = groupId,
|
||||||
string2 = name,
|
string2 = name,
|
||||||
string3 = memberCount,
|
string3 = memberCount,
|
||||||
string4 = lastKeyIndex,
|
string4 = lastKeyIndex,
|
||||||
string5 = newRequired,
|
string5 = newRequired,
|
||||||
string6 = historyVisible
|
string6 = historyVisible,
|
||||||
|
string7 = owner
|
||||||
};
|
};
|
||||||
|
|
||||||
return JsonSerializer.Serialize(info, AppJsonSerializerContext.Default.Universal6String);
|
return JsonSerializer.Serialize(info, AppJsonSerializerContext.Default.Universal7String);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<string> GroupInvite(string id, string groupId, string targetId, string keysPayload = "")
|
public static async Task<string> GroupInvite(string id, string groupId, string targetId, string keysPayload = "")
|
||||||
|
|
@ -857,6 +877,24 @@ public class Groups
|
||||||
|
|
||||||
// Notify remaining members via WebSocket
|
// Notify remaining members via WebSocket
|
||||||
string[] currentMembers = Fs.ReadDirectory($"{groupPath}/members");
|
string[] currentMembers = Fs.ReadDirectory($"{groupPath}/members");
|
||||||
|
|
||||||
|
// Check if leaving user was owner, transfer ownership if possible
|
||||||
|
if (Fs.Exists($"{groupPath}/owner"))
|
||||||
|
{
|
||||||
|
string currentOwner = Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/owner"));
|
||||||
|
if (currentOwner == $"{userId};{Utils.Utils.DOMAIN}")
|
||||||
|
{
|
||||||
|
if (currentMembers.Length > 0)
|
||||||
|
{
|
||||||
|
await Fs.WriteFile($"{groupPath}/owner", Encoding.UTF8.GetBytes(currentMembers[0]));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fs.DeleteFile($"{groupPath}/owner");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (string memberWD in currentMembers)
|
foreach (string memberWD in currentMembers)
|
||||||
{
|
{
|
||||||
string memberStr = Path.GetFileName(memberWD);
|
string memberStr = Path.GetFileName(memberWD);
|
||||||
|
|
@ -884,4 +922,159 @@ public class Groups
|
||||||
|
|
||||||
return "success";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task<string> GroupKickMember(string id, string groupId, string targetId)
|
||||||
|
{
|
||||||
|
string groupPath = GetGroupPath(groupId);
|
||||||
|
if (!Fs.Exists(groupPath)) return "error:group.not.found";
|
||||||
|
|
||||||
|
string ownerPath = $"{groupPath}/owner";
|
||||||
|
if (!Fs.Exists(ownerPath)) return "error:forbidden";
|
||||||
|
|
||||||
|
string currentOwner = Encoding.UTF8.GetString(await Fs.ReadFile(ownerPath));
|
||||||
|
if (currentOwner != $"{id};{DOMAIN}") return "error:forbidden";
|
||||||
|
|
||||||
|
string tId = Account.Utils.GetValidIdOrZero(targetId);
|
||||||
|
if (tId == "0") return "error:invalid.user";
|
||||||
|
|
||||||
|
bool isLocal = Account.Utils.IsUserLocal(targetId, out string tDomain);
|
||||||
|
string memberPath = $"{groupPath}/members/{tId};{tDomain}";
|
||||||
|
|
||||||
|
SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId);
|
||||||
|
await roomLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Fs.Exists(memberPath)) return "error:not.a.member";
|
||||||
|
if (tId == id) return "error:cannot.kick.self";
|
||||||
|
|
||||||
|
Fs.DeleteFile(memberPath);
|
||||||
|
|
||||||
|
string userGroupPath = $"{Utils.Utils.ACCOUNTS_DATA_DIR}/{tId}/groups/{groupId}";
|
||||||
|
if (Fs.Exists(userGroupPath))
|
||||||
|
{
|
||||||
|
Fs.DeleteFile(userGroupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Fs.WriteFile($"{groupPath}/keys/newrequired", Encoding.UTF8.GetBytes("true"));
|
||||||
|
|
||||||
|
string targetName = await Account.Utils.NameFromId(tId);
|
||||||
|
string targetNameWithHost = isLocal ? targetName : $"{targetName}:{tDomain}";
|
||||||
|
|
||||||
|
Message msg = new();
|
||||||
|
msg.attachment = "";
|
||||||
|
msg.author = id;
|
||||||
|
msg.timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
||||||
|
msg.type = "larp.info.kick";
|
||||||
|
msg.content = $"{tId};{tDomain}:{targetNameWithHost}";
|
||||||
|
msg.key = "";
|
||||||
|
msg.pervious = "";
|
||||||
|
msg.responded = "";
|
||||||
|
|
||||||
|
ulong msgId = 0;
|
||||||
|
if (Fs.Exists($"{groupPath}/messages/last"))
|
||||||
|
{
|
||||||
|
msgId = ulong.Parse(Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/messages/last")));
|
||||||
|
msgId++;
|
||||||
|
}
|
||||||
|
await Fs.WriteFile($"{groupPath}/messages/last", Encoding.UTF8.GetBytes(msgId.ToString()));
|
||||||
|
await Fs.WriteFile($"{groupPath}/messages/{msgId}", Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg, AppJsonSerializerContext.Default.Message)));
|
||||||
|
|
||||||
|
string[] currentMembers = Fs.ReadDirectory($"{groupPath}/members");
|
||||||
|
foreach (string memberWD in currentMembers)
|
||||||
|
{
|
||||||
|
string memberStr = Path.GetFileName(memberWD);
|
||||||
|
string memberIdStr = Account.Utils.GetValidIdOrZero(memberStr);
|
||||||
|
if (Account.Utils.IsUserLocal(memberStr, out string d))
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Utils.Utils.SendWsMessage(memberIdStr, $"group_message:{groupId}:unread");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLocal)
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Utils.Utils.SendWsMessage(tId, $"group_removed:{groupId}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
roomLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> GroupTransferOwner(string id, string groupId, string targetId)
|
||||||
|
{
|
||||||
|
string groupPath = GetGroupPath(groupId);
|
||||||
|
if (!Fs.Exists(groupPath)) return "error:group.not.found";
|
||||||
|
|
||||||
|
string ownerPath = $"{groupPath}/owner";
|
||||||
|
if (!Fs.Exists(ownerPath)) return "error:forbidden";
|
||||||
|
|
||||||
|
string currentOwner = Encoding.UTF8.GetString(await Fs.ReadFile(ownerPath));
|
||||||
|
if (currentOwner != $"{id};{DOMAIN}") return "error:forbidden";
|
||||||
|
|
||||||
|
string tId = Account.Utils.GetValidIdOrZero(targetId);
|
||||||
|
if (tId == "0") return "error:invalid.user";
|
||||||
|
|
||||||
|
bool isLocal = Account.Utils.IsUserLocal(targetId, out string tDomain);
|
||||||
|
string memberPath = $"{groupPath}/members/{tId};{tDomain}";
|
||||||
|
|
||||||
|
SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId);
|
||||||
|
await roomLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Fs.Exists(memberPath)) return "error:not.a.member";
|
||||||
|
|
||||||
|
await Fs.WriteFile(ownerPath, Encoding.UTF8.GetBytes($"{tId};{tDomain}"));
|
||||||
|
|
||||||
|
string targetName = await Account.Utils.NameFromId(tId);
|
||||||
|
string targetNameWithHost = isLocal ? targetName : $"{targetName}:{tDomain}";
|
||||||
|
|
||||||
|
Message msg = new();
|
||||||
|
msg.attachment = "";
|
||||||
|
msg.author = id;
|
||||||
|
msg.timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
||||||
|
msg.type = "larp.info.transfer_owner";
|
||||||
|
msg.content = $"{tId};{tDomain}:{targetNameWithHost}";
|
||||||
|
msg.key = "";
|
||||||
|
msg.pervious = "";
|
||||||
|
msg.responded = "";
|
||||||
|
|
||||||
|
ulong msgId = 0;
|
||||||
|
if (Fs.Exists($"{groupPath}/messages/last"))
|
||||||
|
{
|
||||||
|
msgId = ulong.Parse(Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/messages/last")));
|
||||||
|
msgId++;
|
||||||
|
}
|
||||||
|
await Fs.WriteFile($"{groupPath}/messages/last", Encoding.UTF8.GetBytes(msgId.ToString()));
|
||||||
|
await Fs.WriteFile($"{groupPath}/messages/{msgId}", Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg, AppJsonSerializerContext.Default.Message)));
|
||||||
|
|
||||||
|
string[] currentMembers = Fs.ReadDirectory($"{groupPath}/members");
|
||||||
|
foreach (string memberWD in currentMembers)
|
||||||
|
{
|
||||||
|
string memberStr = Path.GetFileName(memberWD);
|
||||||
|
string memberIdStr = Account.Utils.GetValidIdOrZero(memberStr);
|
||||||
|
if (Account.Utils.IsUserLocal(memberStr, out string d))
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Utils.Utils.SendWsMessage(memberIdStr, $"group_message:{groupId}:unread");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
roomLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,48 @@ public class Shared
|
||||||
return Fs.Exists(memberPath);
|
return Fs.Exists(memberPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task<string> GetUserRole(string id, string roomType, string roomId, string targetUserId)
|
||||||
|
{
|
||||||
|
if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden";
|
||||||
|
|
||||||
|
string targetMemberPath = $"{GetRoomPath(roomType, roomId)}/members/{targetUserId}";
|
||||||
|
if (!Fs.Exists(targetMemberPath)) return "error:not.a.member";
|
||||||
|
|
||||||
|
if (roomType == "dms")
|
||||||
|
{
|
||||||
|
return "member";
|
||||||
|
}
|
||||||
|
else if (roomType == "groups")
|
||||||
|
{
|
||||||
|
string ownerPath = $"{Groups.GetGroupPath(roomId)}/owner";
|
||||||
|
if (Fs.Exists(ownerPath))
|
||||||
|
{
|
||||||
|
string owner = Encoding.UTF8.GetString(await Fs.ReadFile(ownerPath));
|
||||||
|
if (owner == targetUserId)
|
||||||
|
{
|
||||||
|
return "group owner";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "member";
|
||||||
|
}
|
||||||
|
else if (roomType == "spaces")
|
||||||
|
{
|
||||||
|
// Placeholder for spaces where custom roles will be implemented
|
||||||
|
string ownerPath = $"{ROOMS_DIR}/{roomType}/{DOMAIN}/{roomId}/owner";
|
||||||
|
if (Fs.Exists(ownerPath))
|
||||||
|
{
|
||||||
|
string owner = Encoding.UTF8.GetString(await Fs.ReadFile(ownerPath));
|
||||||
|
if (owner == targetUserId)
|
||||||
|
{
|
||||||
|
return "Owner";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "member";
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<string> GetMessages(string id, string roomType, string roomId, string startOffset, string countStr)
|
public static async Task<string> GetMessages(string id, string roomType, string roomId, string startOffset, string countStr)
|
||||||
{
|
{
|
||||||
if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden";
|
if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden";
|
||||||
|
|
@ -276,7 +318,18 @@ public class Shared
|
||||||
AppJsonSerializerContext.Default.Message
|
AppJsonSerializerContext.Default.Message
|
||||||
);
|
);
|
||||||
|
|
||||||
if (msg.author != id) return "error:forbidden";
|
if (msg.author != id)
|
||||||
|
{
|
||||||
|
if (roomType == "groups")
|
||||||
|
{
|
||||||
|
string role = await GetUserRole(id, roomType, roomId, id);
|
||||||
|
if (role != "group owner") return "error:forbidden";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return "error:forbidden";
|
||||||
|
}
|
||||||
|
}
|
||||||
if (msg.type == "larp.redacted") return "error:message.already.deleted";
|
if (msg.type == "larp.redacted") return "error:message.already.deleted";
|
||||||
|
|
||||||
msg.type = "larp.redacted";
|
msg.type = "larp.redacted";
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ using System.Collections.Generic;
|
||||||
[JsonSerializable(typeof(Universal4String))]
|
[JsonSerializable(typeof(Universal4String))]
|
||||||
[JsonSerializable(typeof(Universal5String))]
|
[JsonSerializable(typeof(Universal5String))]
|
||||||
[JsonSerializable(typeof(Universal6String))]
|
[JsonSerializable(typeof(Universal6String))]
|
||||||
|
[JsonSerializable(typeof(Universal7String))]
|
||||||
[JsonSerializable(typeof(ServerInfo))]
|
[JsonSerializable(typeof(ServerInfo))]
|
||||||
[JsonSerializable(typeof(Message))]
|
[JsonSerializable(typeof(Message))]
|
||||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,17 @@ public class Universal6String
|
||||||
public string string6 { get; set; }
|
public string string6 { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class Universal7String
|
||||||
|
{
|
||||||
|
public string string1 { get; set; }
|
||||||
|
public string string2 { get; set; }
|
||||||
|
public string string3 { get; set; }
|
||||||
|
public string string4 { get; set; }
|
||||||
|
public string string5 { get; set; }
|
||||||
|
public string string6 { get; set; }
|
||||||
|
public string string7 { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class KeyExchangePayload
|
public class KeyExchangePayload
|
||||||
{
|
{
|
||||||
public string pubX25519 { get; set; }
|
public string pubX25519 { get; set; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue