mirror of
https://forge.fsky.io/lda/Parsee.git
synced 2026-03-13 13:45:10 +00:00
[MOD] Remove the last [p]based nicks, table moving
This commit is contained in:
parent
198bdb98e9
commit
c975dba852
9 changed files with 344 additions and 208 deletions
|
|
@ -11,9 +11,10 @@ I hate Bifrost. I also wanted to dip my toes in XMPP, XML, and bridges a bit. Al
|
|||
this means that I can integrate Parsee with KappaChat however I wish it to be, which allows me to mess around with a
|
||||
codebase I'm already familiar with.
|
||||
A more "up-to-date" reason may be to have a small, 'Just Werks' bridging solution *that is good*.
|
||||
Well, I'm *trying* to do that, at least. Please scream at me if that fails(or just doesn't run
|
||||
on a overclocked Raspberry Pi 4B, which, by the way, was literally where Parsee+XMPP ran for
|
||||
a good chunk of Parsee's start.)
|
||||
|
||||
Well, I'm *trying* to do that, at least.
|
||||
Please scream at me if that fails(or just doesn't run on a overclocked Raspberry
|
||||
Pi 4B, which, by the way, is literally where Parsee+XMPP is running for now.)
|
||||
|
||||
### "Why not just use Matrix lol"
|
||||
### "Why not just use XMPP lol"
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ Main(Array *args, HashMap *env)
|
|||
int http = 8;
|
||||
|
||||
ArgParseStateInit(&state);
|
||||
/* TODO: Have a smarter way of generating the arg table
|
||||
* (with a list of structs, with a description and everything) */
|
||||
while ((flag = ArgParse(&state, args, "vgH:J:")) != -1)
|
||||
{
|
||||
switch (flag)
|
||||
|
|
@ -76,6 +78,9 @@ Main(Array *args, HashMap *env)
|
|||
case 'v':
|
||||
LogConfigLevelSet(LogConfigGlobal(), LOG_DEBUG);
|
||||
break;
|
||||
case '?':
|
||||
Log(LOG_ERR, "INVALID ARGUMENT GIVEN");
|
||||
goto end;
|
||||
}
|
||||
}
|
||||
ParseeSetThreads(xmpp, http);
|
||||
|
|
@ -100,6 +105,7 @@ Main(Array *args, HashMap *env)
|
|||
ParseeInitialiseJIDTable();
|
||||
ParseeInitialiseOIDTable();
|
||||
ParseeInitialiseHeadTable();
|
||||
ParseeInitialiseNickTable();
|
||||
|
||||
conf.port = parsee_conf->port;
|
||||
conf.threads = parsee_conf->http_threads;
|
||||
|
|
@ -159,6 +165,7 @@ end:
|
|||
CronStop(cron);
|
||||
CronFree(cron);
|
||||
ParseeFreeData(conf.handlerArgs);
|
||||
ParseeDestroyNickTable();
|
||||
ParseeDestroyOIDTable();
|
||||
ParseeDestroyHeadTable();
|
||||
ParseeDestroyJIDTable();
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ JoinMUC(ParseeData *data, HashMap *event, char *jid, char *muc, char *name)
|
|||
{
|
||||
char *sender = GrabString(event, 1, "sender");
|
||||
|
||||
char *rev = StrConcat(3, muc, "/", name);
|
||||
char *nick = StrDuplicate(name);
|
||||
char *rev = StrConcat(3, muc, "/", nick);
|
||||
int nonce = 0;
|
||||
|
||||
while (!XMPPJoinMUC(data->jabber, jid, rev, true) && nonce < 20)
|
||||
|
|
@ -32,8 +33,11 @@ JoinMUC(ParseeData *data, HashMap *event, char *jid, char *muc, char *name)
|
|||
hex[8] = '\0';
|
||||
}
|
||||
|
||||
Free(nick);
|
||||
Free(rev);
|
||||
rev = StrConcat(6, muc, "/", name, "[", hex, "]");
|
||||
|
||||
nick = StrConcat(4, name, "[", hex, "]");
|
||||
rev = StrConcat(3, muc, "/", nick);
|
||||
nonce++;
|
||||
|
||||
Free(nonce_str);
|
||||
|
|
@ -41,6 +45,9 @@ JoinMUC(ParseeData *data, HashMap *event, char *jid, char *muc, char *name)
|
|||
Free(input);
|
||||
Free(hex);
|
||||
}
|
||||
|
||||
ParseePushNickTable(muc, sender, nick);
|
||||
Free(nick);
|
||||
Free(rev);
|
||||
}
|
||||
|
||||
|
|
@ -118,11 +125,15 @@ ParseeMemberHandler(ParseeData *data, HashMap *event)
|
|||
goto end;
|
||||
}
|
||||
|
||||
/* TODO: Check the name's validity */
|
||||
name = ASGetName(data->config, room_id, state_key);
|
||||
rev = StrConcat(4, muc_id, "/", name, "[p]");
|
||||
/* TODO: We need to deal with the nick properly, as XMPP
|
||||
* requires us to provide it whenever we want to even think
|
||||
* about leaving...
|
||||
* I love how this is the last place victim of the dreaded [p]... */
|
||||
name = StrDuplicate(ParseeLookupNick(muc_id, sender));
|
||||
rev = StrConcat(3, muc_id, "/", name);
|
||||
|
||||
XMPPLeaveMUC(jabber, jid, rev, reason);
|
||||
ParseePushNickTable(muc_id, sender, NULL);
|
||||
end:
|
||||
Free(chat_id);
|
||||
Free(muc_id);
|
||||
|
|
@ -239,13 +250,7 @@ GetXMPPInformation(ParseeData *data, HashMap *event, char **from, char **to)
|
|||
}
|
||||
|
||||
matrix_name = ASGetName(data->config, room_id, matrix_sender);
|
||||
|
||||
/* TODO: Manage name conflicts. That would have been an easy
|
||||
* task(try the original one, and use a counter if it fails),
|
||||
* but that'd involve modifying the rest of the code, which
|
||||
* I'm not doing at 01:39 ... */
|
||||
JoinMUC(data, event, *from, muc_id, matrix_name);
|
||||
|
||||
*to = muc_id;
|
||||
|
||||
Free(matrix_name);
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
#include <Parsee.h>
|
||||
|
||||
#include <Cytoplasm/HashMap.h>
|
||||
#include <Cytoplasm/Memory.h>
|
||||
#include <Cytoplasm/Str.h>
|
||||
#include <pthread.h>
|
||||
|
||||
static pthread_mutex_t lock;
|
||||
static HashMap *jid_table = NULL;
|
||||
|
||||
void
|
||||
ParseeInitialiseJIDTable(void)
|
||||
{
|
||||
if (jid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_init(&lock, NULL);
|
||||
pthread_mutex_lock(&lock);
|
||||
jid_table = HashMapCreate();
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
void
|
||||
ParseePushJIDTable(char *muc, char *bare)
|
||||
{
|
||||
if (!muc || !bare || !jid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
bare = ParseeTrimJID(bare);
|
||||
Free(HashMapSet(jid_table, muc, bare));
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
char *
|
||||
ParseeLookupJID(char *muc)
|
||||
{
|
||||
char *bare;
|
||||
if (!muc || !jid_table)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
bare = StrDuplicate(HashMapGet(jid_table, muc));
|
||||
pthread_mutex_unlock(&lock);
|
||||
|
||||
if (!bare)
|
||||
{
|
||||
bare = StrDuplicate(muc);
|
||||
}
|
||||
return bare;
|
||||
}
|
||||
void
|
||||
ParseeDestroyJIDTable(void)
|
||||
{
|
||||
char *key;
|
||||
void *val;
|
||||
if (!jid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
while (HashMapIterate(jid_table, &key, &val))
|
||||
{
|
||||
Free(val);
|
||||
}
|
||||
HashMapFree(jid_table);
|
||||
jid_table = NULL;
|
||||
pthread_mutex_unlock(&lock);
|
||||
pthread_mutex_destroy(&lock);
|
||||
}
|
||||
|
||||
static pthread_mutex_t head_lock;
|
||||
static HashMap *head_table = NULL;
|
||||
void
|
||||
ParseeInitialiseHeadTable(void)
|
||||
{
|
||||
if (head_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_init(&head_lock, NULL);
|
||||
pthread_mutex_lock(&head_lock);
|
||||
head_table = HashMapCreate();
|
||||
pthread_mutex_unlock(&head_lock);
|
||||
}
|
||||
void
|
||||
ParseePushHeadTable(char *room, char *event)
|
||||
{
|
||||
if (!room || !event || !head_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&head_lock);
|
||||
event = StrDuplicate(event);
|
||||
Free(HashMapSet(head_table, room, event));
|
||||
pthread_mutex_unlock(&head_lock);
|
||||
}
|
||||
char *
|
||||
ParseeLookupHead(char *room)
|
||||
{
|
||||
char *event;
|
||||
if (!room || !head_table)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
pthread_mutex_lock(&head_lock);
|
||||
event = StrDuplicate(HashMapGet(head_table, room));
|
||||
pthread_mutex_unlock(&head_lock);
|
||||
|
||||
return event;
|
||||
}
|
||||
void
|
||||
ParseeDestroyHeadTable(void)
|
||||
{
|
||||
char *key;
|
||||
void *val;
|
||||
if (!head_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&head_lock);
|
||||
while (HashMapIterate(head_table, &key, &val))
|
||||
{
|
||||
Free(val);
|
||||
}
|
||||
HashMapFree(head_table);
|
||||
head_table = NULL;
|
||||
pthread_mutex_unlock(&head_lock);
|
||||
pthread_mutex_destroy(&head_lock);
|
||||
}
|
||||
|
||||
|
||||
static pthread_mutex_t oid_lock;
|
||||
static HashMap *oid_table = NULL;
|
||||
|
||||
void
|
||||
ParseeInitialiseOIDTable(void)
|
||||
{
|
||||
if (oid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_init(&oid_lock, NULL);
|
||||
pthread_mutex_lock(&oid_lock);
|
||||
oid_table = HashMapCreate();
|
||||
pthread_mutex_unlock(&oid_lock);
|
||||
}
|
||||
void
|
||||
ParseePushOIDTable(char *muc, char *bare)
|
||||
{
|
||||
if (!muc || !bare || !oid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&oid_lock);
|
||||
bare = StrDuplicate(bare);
|
||||
Free(HashMapSet(oid_table, muc, bare));
|
||||
pthread_mutex_unlock(&oid_lock);
|
||||
}
|
||||
char *
|
||||
ParseeLookupOID(char *muc)
|
||||
{
|
||||
char *bare;
|
||||
if (!muc || !oid_table)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
pthread_mutex_lock(&oid_lock);
|
||||
bare = StrDuplicate(HashMapGet(oid_table, muc));
|
||||
pthread_mutex_unlock(&oid_lock);
|
||||
|
||||
return bare;
|
||||
}
|
||||
void
|
||||
ParseeDestroyOIDTable(void)
|
||||
{
|
||||
char *key;
|
||||
void *val;
|
||||
if (!oid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&oid_lock);
|
||||
while (HashMapIterate(oid_table, &key, &val))
|
||||
{
|
||||
Free(val);
|
||||
}
|
||||
HashMapFree(oid_table);
|
||||
oid_table = NULL;
|
||||
pthread_mutex_unlock(&oid_lock);
|
||||
pthread_mutex_destroy(&oid_lock);
|
||||
}
|
||||
|
||||
67
src/Parsee/Tables/HeadTable.c
Normal file
67
src/Parsee/Tables/HeadTable.c
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#include <Parsee.h>
|
||||
|
||||
#include <Cytoplasm/HashMap.h>
|
||||
#include <Cytoplasm/Memory.h>
|
||||
#include <Cytoplasm/Str.h>
|
||||
#include <pthread.h>
|
||||
|
||||
static pthread_mutex_t head_lock;
|
||||
static HashMap *head_table = NULL;
|
||||
void
|
||||
ParseeInitialiseHeadTable(void)
|
||||
{
|
||||
if (head_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_init(&head_lock, NULL);
|
||||
pthread_mutex_lock(&head_lock);
|
||||
head_table = HashMapCreate();
|
||||
pthread_mutex_unlock(&head_lock);
|
||||
}
|
||||
void
|
||||
ParseePushHeadTable(char *room, char *event)
|
||||
{
|
||||
if (!room || !event || !head_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&head_lock);
|
||||
event = StrDuplicate(event);
|
||||
Free(HashMapSet(head_table, room, event));
|
||||
pthread_mutex_unlock(&head_lock);
|
||||
}
|
||||
char *
|
||||
ParseeLookupHead(char *room)
|
||||
{
|
||||
char *event;
|
||||
if (!room || !head_table)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
pthread_mutex_lock(&head_lock);
|
||||
event = StrDuplicate(HashMapGet(head_table, room));
|
||||
pthread_mutex_unlock(&head_lock);
|
||||
|
||||
return event;
|
||||
}
|
||||
void
|
||||
ParseeDestroyHeadTable(void)
|
||||
{
|
||||
char *key;
|
||||
void *val;
|
||||
if (!head_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&head_lock);
|
||||
while (HashMapIterate(head_table, &key, &val))
|
||||
{
|
||||
Free(val);
|
||||
}
|
||||
HashMapFree(head_table);
|
||||
head_table = NULL;
|
||||
pthread_mutex_unlock(&head_lock);
|
||||
pthread_mutex_destroy(&head_lock);
|
||||
}
|
||||
|
||||
72
src/Parsee/Tables/JIDTable.c
Normal file
72
src/Parsee/Tables/JIDTable.c
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#include <Parsee.h>
|
||||
|
||||
#include <Cytoplasm/HashMap.h>
|
||||
#include <Cytoplasm/Memory.h>
|
||||
#include <Cytoplasm/Str.h>
|
||||
#include <pthread.h>
|
||||
|
||||
static pthread_mutex_t lock;
|
||||
static HashMap *jid_table = NULL;
|
||||
|
||||
void
|
||||
ParseeInitialiseJIDTable(void)
|
||||
{
|
||||
if (jid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_init(&lock, NULL);
|
||||
pthread_mutex_lock(&lock);
|
||||
jid_table = HashMapCreate();
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
void
|
||||
ParseePushJIDTable(char *muc, char *bare)
|
||||
{
|
||||
if (!muc || !bare || !jid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
bare = ParseeTrimJID(bare);
|
||||
Free(HashMapSet(jid_table, muc, bare));
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
char *
|
||||
ParseeLookupJID(char *muc)
|
||||
{
|
||||
char *bare;
|
||||
if (!muc || !jid_table)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
bare = StrDuplicate(HashMapGet(jid_table, muc));
|
||||
pthread_mutex_unlock(&lock);
|
||||
|
||||
if (!bare)
|
||||
{
|
||||
bare = StrDuplicate(muc);
|
||||
}
|
||||
return bare;
|
||||
}
|
||||
void
|
||||
ParseeDestroyJIDTable(void)
|
||||
{
|
||||
char *key;
|
||||
void *val;
|
||||
if (!jid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
while (HashMapIterate(jid_table, &key, &val))
|
||||
{
|
||||
Free(val);
|
||||
}
|
||||
HashMapFree(jid_table);
|
||||
jid_table = NULL;
|
||||
pthread_mutex_unlock(&lock);
|
||||
pthread_mutex_destroy(&lock);
|
||||
}
|
||||
|
||||
105
src/Parsee/Tables/NickTable.c
Normal file
105
src/Parsee/Tables/NickTable.c
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#include <Parsee.h>
|
||||
|
||||
#include <Cytoplasm/HashMap.h>
|
||||
#include <Cytoplasm/Memory.h>
|
||||
#include <Cytoplasm/Str.h>
|
||||
#include <Cytoplasm/Sha.h>
|
||||
#include <pthread.h>
|
||||
|
||||
static pthread_mutex_t nick_lock;
|
||||
static HashMap *nick_table = NULL;
|
||||
|
||||
static char *
|
||||
GenerateKey(char *muc, char *mxid)
|
||||
{
|
||||
unsigned char *shaDigest;
|
||||
|
||||
char *concatStr;
|
||||
char *hexDigest;
|
||||
if (!muc || !mxid)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
concatStr = StrConcat(3, muc, ":", mxid);
|
||||
shaDigest = Sha256(concatStr);
|
||||
hexDigest = ShaToHex(shaDigest);
|
||||
|
||||
Free (shaDigest);
|
||||
Free (concatStr);
|
||||
return hexDigest;
|
||||
}
|
||||
|
||||
void
|
||||
ParseeInitialiseNickTable(void)
|
||||
{
|
||||
if (nick_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_init(&nick_lock, NULL);
|
||||
pthread_mutex_lock(&nick_lock);
|
||||
nick_table = HashMapCreate();
|
||||
pthread_mutex_unlock(&nick_lock);
|
||||
}
|
||||
void
|
||||
ParseePushNickTable(char *muc, char *mxid, char *nick)
|
||||
{
|
||||
char *key;
|
||||
if (!muc || !mxid || !nick_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&nick_lock);
|
||||
|
||||
key = GenerateKey(muc, mxid);
|
||||
nick = StrDuplicate(nick);
|
||||
if (nick)
|
||||
{
|
||||
Free(HashMapSet(nick_table, key, nick));
|
||||
}
|
||||
else
|
||||
{
|
||||
Free(HashMapDelete(nick_table, key));
|
||||
}
|
||||
Free(key);
|
||||
|
||||
pthread_mutex_unlock(&nick_lock);
|
||||
}
|
||||
char *
|
||||
ParseeLookupNick(char *muc, char *mxid)
|
||||
{
|
||||
char *ret, *key;
|
||||
if (!muc || !nick_table)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
pthread_mutex_lock(&nick_lock);
|
||||
|
||||
key = GenerateKey(muc, mxid);
|
||||
ret = HashMapGet(nick_table, key);
|
||||
Free(key);
|
||||
|
||||
pthread_mutex_unlock(&nick_lock);
|
||||
return ret;
|
||||
}
|
||||
void
|
||||
ParseeDestroyNickTable(void)
|
||||
{
|
||||
char *key;
|
||||
void *val;
|
||||
if (!nick_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&nick_lock);
|
||||
while (HashMapIterate(nick_table, &key, &val))
|
||||
{
|
||||
Free(val);
|
||||
}
|
||||
HashMapFree(nick_table);
|
||||
nick_table = NULL;
|
||||
pthread_mutex_unlock(&nick_lock);
|
||||
pthread_mutex_destroy(&nick_lock);
|
||||
}
|
||||
|
||||
68
src/Parsee/Tables/OIDTable.c
Normal file
68
src/Parsee/Tables/OIDTable.c
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#include <Parsee.h>
|
||||
|
||||
#include <Cytoplasm/HashMap.h>
|
||||
#include <Cytoplasm/Memory.h>
|
||||
#include <Cytoplasm/Str.h>
|
||||
#include <pthread.h>
|
||||
|
||||
static pthread_mutex_t oid_lock;
|
||||
static HashMap *oid_table = NULL;
|
||||
|
||||
void
|
||||
ParseeInitialiseOIDTable(void)
|
||||
{
|
||||
if (oid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_init(&oid_lock, NULL);
|
||||
pthread_mutex_lock(&oid_lock);
|
||||
oid_table = HashMapCreate();
|
||||
pthread_mutex_unlock(&oid_lock);
|
||||
}
|
||||
void
|
||||
ParseePushOIDTable(char *muc, char *bare)
|
||||
{
|
||||
if (!muc || !bare || !oid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&oid_lock);
|
||||
bare = StrDuplicate(bare);
|
||||
Free(HashMapSet(oid_table, muc, bare));
|
||||
pthread_mutex_unlock(&oid_lock);
|
||||
}
|
||||
char *
|
||||
ParseeLookupOID(char *muc)
|
||||
{
|
||||
char *bare;
|
||||
if (!muc || !oid_table)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
pthread_mutex_lock(&oid_lock);
|
||||
bare = StrDuplicate(HashMapGet(oid_table, muc));
|
||||
pthread_mutex_unlock(&oid_lock);
|
||||
|
||||
return bare;
|
||||
}
|
||||
void
|
||||
ParseeDestroyOIDTable(void)
|
||||
{
|
||||
char *key;
|
||||
void *val;
|
||||
if (!oid_table)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&oid_lock);
|
||||
while (HashMapIterate(oid_table, &key, &val))
|
||||
{
|
||||
Free(val);
|
||||
}
|
||||
HashMapFree(oid_table);
|
||||
oid_table = NULL;
|
||||
pthread_mutex_unlock(&oid_lock);
|
||||
pthread_mutex_destroy(&oid_lock);
|
||||
}
|
||||
|
||||
|
|
@ -239,6 +239,11 @@ extern void ParseePushHeadTable(char *room, char *id);
|
|||
extern char *ParseeLookupHead(char *room);
|
||||
extern void ParseeDestroyHeadTable(void);
|
||||
|
||||
extern void ParseeInitialiseNickTable(void);
|
||||
extern void ParseePushNickTable(char *muc, char *mxid, char *nick);
|
||||
extern char *ParseeLookupNick(char *muc, char *mxid);
|
||||
extern void ParseeDestroyNickTable(void);
|
||||
|
||||
/** Disables a user/room/MUC's ability to interact from Parsee, and attempts
|
||||
* to ban them from rooms where Parsee has the ability to do so ("noflying").
|
||||
* ---------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue