(info): Fix when with last character is special.
Also reorganized to check for special characters then blank. (info): Add length boundary check to filename variable. Change WIN32 to use the same code. (info) [_WIN32]: Fix check for invalid file attributes. (apro): Ensure the user does not see the directory information on error. See #1028552 for reference. (apro): Ensure the directories, operating system files, and hidden files are skipped.
This commit is contained in:
parent
adfc536122
commit
8fdd0259f2
1 changed files with 120 additions and 113 deletions
|
@ -64,22 +64,6 @@ lowerit(s_char *buf, int n, s_char *orig)
|
||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int
|
|
||||||
strnccmp(s_char *s1, s_char *s2, int n)
|
|
||||||
{
|
|
||||||
int i;
|
|
||||||
char c1, c2;
|
|
||||||
for (i = 0; i < n && *s1 && s2; i++) {
|
|
||||||
c1 = tolower(*s1++);
|
|
||||||
c2 = tolower(*s2++);
|
|
||||||
if (c1 > c2)
|
|
||||||
return 1;
|
|
||||||
else if (c1 < c2)
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !defined(_WIN32)
|
#if !defined(_WIN32)
|
||||||
|
|
||||||
int
|
int
|
||||||
|
@ -87,25 +71,28 @@ info(void)
|
||||||
{
|
{
|
||||||
s_char buf[255];
|
s_char buf[255];
|
||||||
FILE *fp;
|
FILE *fp;
|
||||||
s_char *bp;
|
s_char *name;
|
||||||
|
s_char *tmp_name;
|
||||||
struct stat statb;
|
struct stat statb;
|
||||||
struct dirent *dp;
|
struct dirent *dp;
|
||||||
s_char filename[1024];
|
s_char filename[1024];
|
||||||
DIR *info_dp;
|
DIR *info_dp;
|
||||||
|
|
||||||
if (player->argp[1] == 0 || !*player->argp[1])
|
name = player->argp[1];
|
||||||
bp = "TOP";
|
if (name) {
|
||||||
/*
|
/*
|
||||||
* don't let sneaky people go outside the info directory
|
* don't let sneaky people go outside the info directory
|
||||||
*/
|
*/
|
||||||
else if (NULL != (bp = strrchr(player->argp[1], '/')))
|
if (NULL != (tmp_name = strrchr(name, '/')))
|
||||||
bp++;
|
name = tmp_name + 1;
|
||||||
else
|
}
|
||||||
bp = player->argp[1];
|
if (!name || !*name)
|
||||||
sprintf(filename, "%s/%s", infodir, bp);
|
name = "TOP";
|
||||||
|
|
||||||
|
snprintf(filename, sizeof(filename), "%s/%s", infodir, name);
|
||||||
fp = fopen(filename, "r");
|
fp = fopen(filename, "r");
|
||||||
if (fp == NULL) {
|
if (fp == NULL) {
|
||||||
int len = strlen(bp);
|
int len = strlen(name);
|
||||||
/* may be a "partial" request. */
|
/* may be a "partial" request. */
|
||||||
info_dp = opendir(infodir);
|
info_dp = opendir(infodir);
|
||||||
if (info_dp == 0) {
|
if (info_dp == 0) {
|
||||||
|
@ -113,34 +100,34 @@ info(void)
|
||||||
logerror("Can't open info dir \"%s\"\n", infodir);
|
logerror("Can't open info dir \"%s\"\n", infodir);
|
||||||
return RET_SYS;
|
return RET_SYS;
|
||||||
}
|
}
|
||||||
rewinddir(info_dp);
|
|
||||||
while ((dp = readdir(info_dp)) != 0 && fp == 0) {
|
while ((dp = readdir(info_dp)) != 0 && fp == 0) {
|
||||||
if (strnccmp(bp, dp->d_name, len) != 0)
|
if (strncasecmp(name, dp->d_name, strlen(name)) != 0)
|
||||||
continue;
|
continue;
|
||||||
sprintf(filename, "%s/%s", infodir, dp->d_name);
|
sprintf(filename, "%s/%s", infodir, dp->d_name);
|
||||||
fp = fopen(filename, "r");
|
fp = fopen(filename, "r");
|
||||||
}
|
}
|
||||||
closedir(info_dp);
|
closedir(info_dp);
|
||||||
if (fp == NULL) {
|
if (fp == NULL) {
|
||||||
pr("Sorry, there is no info on %s\n", bp);
|
pr("Sorry, there is no info on %s\n", name);
|
||||||
return RET_FAIL;
|
return RET_FAIL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fstat(fileno(fp), &statb) < 0) {
|
if (fstat(fileno(fp), &statb) < 0) {
|
||||||
pr("Error reading info file for %s\n", bp);
|
pr("Error reading info file for %s\n", name);
|
||||||
logerror("Cannot fstat for \"%s\" info file (%s)",
|
logerror("Cannot fstat for \"%s\" info file (%s)",
|
||||||
filename, strerror(errno));
|
filename, strerror(errno));
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
return RET_SYS;
|
return RET_SYS;
|
||||||
}
|
}
|
||||||
if ((statb.st_mode & S_IFREG) == 0) {
|
if ((statb.st_mode & S_IFREG) == 0) {
|
||||||
pr("Error reading info file for %s\n", bp);
|
pr("Error reading info file for %s\n", name);
|
||||||
logerror("The info file \"%s\" is not regular file\n", filename);
|
logerror("The info file \"%s\" is not regular file\n", filename);
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
return RET_SYS;
|
return RET_SYS;
|
||||||
}
|
}
|
||||||
pr("Information on: %s Last modification date: %s",
|
pr("Information on: %s Last modification date: %s",
|
||||||
bp, ctime(&statb.st_mtime));
|
name, ctime(&statb.st_mtime));
|
||||||
while (fgets(buf, sizeof(buf), fp) != 0)
|
while (fgets(buf, sizeof(buf), fp) != 0)
|
||||||
pr("%s", buf);
|
pr("%s", buf);
|
||||||
(void)fclose(fp);
|
(void)fclose(fp);
|
||||||
|
@ -151,7 +138,7 @@ int
|
||||||
apro(void)
|
apro(void)
|
||||||
{
|
{
|
||||||
FILE *fp;
|
FILE *fp;
|
||||||
s_char *bp, *lbp;
|
s_char *name, *lbp;
|
||||||
s_char *fbuf;
|
s_char *fbuf;
|
||||||
s_char *lbuf;
|
s_char *lbuf;
|
||||||
struct dirent *dp;
|
struct dirent *dp;
|
||||||
|
@ -160,6 +147,7 @@ apro(void)
|
||||||
long nf, nhf, nl, nlhl, nhl, nll;
|
long nf, nhf, nl, nlhl, nhl, nll;
|
||||||
int alreadyhit;
|
int alreadyhit;
|
||||||
int lhitlim;
|
int lhitlim;
|
||||||
|
struct stat statb;
|
||||||
|
|
||||||
if (player->argp[1] == 0 || !*player->argp[1]) {
|
if (player->argp[1] == 0 || !*player->argp[1]) {
|
||||||
pr("Apropos what?\n");
|
pr("Apropos what?\n");
|
||||||
|
@ -174,8 +162,9 @@ apro(void)
|
||||||
}
|
}
|
||||||
|
|
||||||
info_dp = opendir(infodir);
|
info_dp = opendir(infodir);
|
||||||
if (info_dp == 0) {
|
if (info_dp == NULL) {
|
||||||
pr("Can't open info dir \"%s\"\n", infodir);
|
pr("Can't open info dir \n");
|
||||||
|
logerror("Can't open info dir \"%s\"", infodir);
|
||||||
return RET_SYS;
|
return RET_SYS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -186,20 +175,32 @@ apro(void)
|
||||||
/*
|
/*
|
||||||
* lower case search string into lbp
|
* lower case search string into lbp
|
||||||
*/
|
*/
|
||||||
bp = player->argp[1];
|
name = player->argp[1];
|
||||||
lowerit(lbp, 256, bp);
|
lowerit(lbp, 256, name);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* search
|
* search
|
||||||
*/
|
*/
|
||||||
nf = nhf = nl = nhl = 0;
|
nf = nhf = nl = nhl = 0;
|
||||||
rewinddir(info_dp);
|
|
||||||
while ((dp = readdir(info_dp)) != 0) {
|
while ((dp = readdir(info_dp)) != 0) {
|
||||||
sprintf(filename, "%s/%s", infodir, dp->d_name);
|
snprintf(filename, sizeof(filename), "%s/%s", infodir,
|
||||||
|
dp->d_name);
|
||||||
fp = fopen(filename, "r");
|
fp = fopen(filename, "r");
|
||||||
alreadyhit = 0;
|
alreadyhit = 0;
|
||||||
nll = nlhl = 0;
|
nll = nlhl = 0;
|
||||||
if (fp != NULL) {
|
if (fp != NULL) {
|
||||||
|
if (fstat(fileno(fp), &statb) < 0) {
|
||||||
|
logerror("Cannot stat for \"%s\" info file (%s)",
|
||||||
|
filename, strerror(errno));
|
||||||
|
fclose(fp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((statb.st_mode & S_IFREG) == 0) {
|
||||||
|
logerror("The info file \"%s\" is not regular file\n",
|
||||||
|
filename);
|
||||||
|
fclose(fp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
while (fgets(fbuf, 256, fp)) {
|
while (fgets(fbuf, 256, fp)) {
|
||||||
lowerit(lbuf, 256, fbuf);
|
lowerit(lbuf, 256, fbuf);
|
||||||
if (strstr(lbuf, lbp)) {
|
if (strstr(lbuf, lbp)) {
|
||||||
|
@ -238,7 +239,7 @@ apro(void)
|
||||||
pr("Limit of %d lines exceeded\n", lhitlim);
|
pr("Limit of %d lines exceeded\n", lhitlim);
|
||||||
}
|
}
|
||||||
pr("Found %s in %ld of %ld files and in %ld of %ld lines\n",
|
pr("Found %s in %ld of %ld files and in %ld of %ld lines\n",
|
||||||
bp, nhf, nf, nhl, nl);
|
name, nhf, nf, nhl, nl);
|
||||||
return RET_OK;
|
return RET_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,42 +250,39 @@ info(void)
|
||||||
{
|
{
|
||||||
s_char buf[255];
|
s_char buf[255];
|
||||||
FILE *fp;
|
FILE *fp;
|
||||||
s_char *bp;
|
s_char *name;
|
||||||
s_char *bp2;
|
s_char *tmp_name;
|
||||||
s_char filename[1024];
|
s_char filename[1024];
|
||||||
|
int nmatch = 0;
|
||||||
|
|
||||||
if (player->argp[1] == 0 || !*player->argp[1])
|
name = player->argp[1];
|
||||||
bp = "TOP";
|
if (name) {
|
||||||
else {
|
|
||||||
/*
|
/*
|
||||||
* don't let sneaky people go outside the info directory
|
* don't let sneaky people go outside the info directory
|
||||||
*/
|
*/
|
||||||
bp = player->argp[1];
|
if (NULL != (tmp_name = strrchr(name, '/')))
|
||||||
if (NULL != (bp2 = strrchr(bp, '/')))
|
name = tmp_name + 1;
|
||||||
bp = ++bp2;
|
if (NULL != (tmp_name = strrchr(name, '\\')))
|
||||||
if (NULL != (bp2 = strrchr(bp, '\\')))
|
name = tmp_name + 1;
|
||||||
bp = ++bp2;
|
if (NULL != (tmp_name = strrchr(name, ':')))
|
||||||
if (NULL != (bp2 = strrchr(bp, ':')))
|
name = tmp_name + 1;
|
||||||
bp = ++bp2;
|
|
||||||
if (!*bp)
|
|
||||||
bp = "TOP";
|
|
||||||
}
|
}
|
||||||
|
if (!name || !*name)
|
||||||
|
name = "TOP";
|
||||||
|
|
||||||
strncpy(filename, infodir, sizeof(filename) - 2);
|
_snprintf(filename, sizeof(filename) - 1, "%s\\%s", infodir, name);
|
||||||
strcat(filename, "\\");
|
|
||||||
strncat(filename, bp, sizeof(filename) - 1 - strlen(filename));
|
|
||||||
fp = fopen(filename, "r");
|
fp = fopen(filename, "r");
|
||||||
if (fp == NULL) {
|
if (fp == NULL) {
|
||||||
/* may be a "partial" request. */
|
/* may be a "partial" request. */
|
||||||
HANDLE hDir;
|
HANDLE hDir;
|
||||||
WIN32_FIND_DATA fData;
|
WIN32_FIND_DATA fData;
|
||||||
int len = strlen(bp);
|
int len = strlen(name);
|
||||||
strncat(filename, "*", sizeof(filename) - 1 - strlen(filename));
|
strcat(filename, "*");
|
||||||
hDir = FindFirstFile(filename, &fData);
|
hDir = FindFirstFile(filename, &fData);
|
||||||
if (hDir == INVALID_HANDLE_VALUE) {
|
if (hDir == INVALID_HANDLE_VALUE) {
|
||||||
switch (GetLastError()) {
|
switch (GetLastError()) {
|
||||||
case ERROR_FILE_NOT_FOUND:
|
case ERROR_FILE_NOT_FOUND:
|
||||||
pr("Sorry, there is no info on %s\n", bp);
|
pr("Sorry, there is no info on %s\n", name);
|
||||||
return RET_FAIL;
|
return RET_FAIL;
|
||||||
break;
|
break;
|
||||||
case ERROR_PATH_NOT_FOUND:
|
case ERROR_PATH_NOT_FOUND:
|
||||||
|
@ -293,43 +291,42 @@ info(void)
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
pr("Error reading info dir\n");
|
pr("Error reading info dir\n");
|
||||||
logerror("Error (%d) reading info dir(%s)/file(%s)",
|
logerror("Error (%d) reading info dir(%s)\\file(%s)",
|
||||||
infodir, filename, GetLastError());
|
infodir, filename, GetLastError());
|
||||||
}
|
}
|
||||||
return RET_SYS;
|
return RET_SYS;
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
if (((fData.dwFileAttributes == FILE_ATTRIBUTE_NORMAL) ||
|
if ((fData.dwFileAttributes != (DWORD)-1) &&
|
||||||
|
((fData.dwFileAttributes == FILE_ATTRIBUTE_NORMAL) ||
|
||||||
(fData.dwFileAttributes == FILE_ATTRIBUTE_ARCHIVE) ||
|
(fData.dwFileAttributes == FILE_ATTRIBUTE_ARCHIVE) ||
|
||||||
(fData.dwFileAttributes == FILE_ATTRIBUTE_READONLY)) &&
|
(fData.dwFileAttributes == FILE_ATTRIBUTE_READONLY)) &&
|
||||||
(strnccmp(bp, fData.cFileName, len) == 0)) {
|
(strncasecmp(name, fData.cFileName, strlen(name)) == 0)) {
|
||||||
strncpy(filename, infodir, sizeof(filename) - 2);
|
_snprintf(filename, sizeof(filename), "%s\\%s", infodir, fData.cFileName);
|
||||||
strcat(filename, "\\");
|
|
||||||
strncat(filename, fData.cFileName,
|
|
||||||
sizeof(filename) - 1 - strlen(filename));
|
|
||||||
fp = fopen(filename, "r");
|
fp = fopen(filename, "r");
|
||||||
}
|
}
|
||||||
} while (!fp && FindNextFile(hDir, &fData));
|
} while (!fp && FindNextFile(hDir, &fData));
|
||||||
FindClose(hDir);
|
FindClose(hDir);
|
||||||
if (fp == NULL) {
|
if (fp == NULL) {
|
||||||
pr("Sorry, there is no info on %s\n", bp);
|
pr("Sorry, there is no info on %s\n", name);
|
||||||
return RET_FAIL;
|
return RET_FAIL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
DWORD fAttrib = GetFileAttributes(filename);
|
DWORD fAttrib = GetFileAttributes(filename);
|
||||||
if ((fAttrib == (DWORD)-1) && //INVALID_FILE_ATTRIBUTES
|
if ((fAttrib == (DWORD)-1) || //INVALID_FILE_ATTRIBUTES
|
||||||
(fAttrib != FILE_ATTRIBUTE_NORMAL) &&
|
(fAttrib != FILE_ATTRIBUTE_NORMAL) &&
|
||||||
(fAttrib != FILE_ATTRIBUTE_ARCHIVE) &&
|
(fAttrib != FILE_ATTRIBUTE_ARCHIVE) &&
|
||||||
(fAttrib != FILE_ATTRIBUTE_READONLY)) {
|
(fAttrib != FILE_ATTRIBUTE_READONLY)) {
|
||||||
pr("Error reading info file for %s\n", bp);
|
pr("Error reading info file for %s\n", name);
|
||||||
logerror("The info file \"%s\" is not regular file\n", filename);
|
logerror("The info file \"%s\" is not regular file\n",
|
||||||
|
filename);
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
return RET_SYS;
|
return RET_SYS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pr("Information on: %s", bp);
|
pr("Information on: %s", name);
|
||||||
while (fgets(buf, sizeof(buf), fp) != 0)
|
while (fgets(buf, sizeof(buf), fp) != 0)
|
||||||
pr("%s", buf);
|
pr("%s", buf);
|
||||||
(void)fclose(fp);
|
(void)fclose(fp);
|
||||||
|
@ -342,7 +339,7 @@ apro(void)
|
||||||
HANDLE hDir;
|
HANDLE hDir;
|
||||||
WIN32_FIND_DATA fData;
|
WIN32_FIND_DATA fData;
|
||||||
FILE *fp;
|
FILE *fp;
|
||||||
s_char *bp, *lbp;
|
s_char *name, *lbp;
|
||||||
s_char *fbuf;
|
s_char *fbuf;
|
||||||
s_char *lbuf;
|
s_char *lbuf;
|
||||||
s_char filename[1024];
|
s_char filename[1024];
|
||||||
|
@ -362,11 +359,18 @@ apro(void)
|
||||||
lhitlim = 100;
|
lhitlim = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
strncpy(filename, infodir, sizeof(filename) - 3);
|
_snprintf(filename, sizeof(filename), "%s\\*",infodir);
|
||||||
strcat(filename, "//*");
|
|
||||||
hDir = FindFirstFile(filename, &fData);
|
hDir = FindFirstFile(filename, &fData);
|
||||||
if (hDir == INVALID_HANDLE_VALUE) {
|
if (hDir == INVALID_HANDLE_VALUE) {
|
||||||
return RET_FAIL;
|
if (GetLastError() == ERROR_PATH_NOT_FOUND) {
|
||||||
|
pr("Can't open info dir\n");
|
||||||
|
logerror("Can't open info dir \"%s\"", infodir);
|
||||||
|
} else {
|
||||||
|
pr("Error reading info dir\n");
|
||||||
|
logerror("Error (%d) reading info dir(%s)\\file(%s)",
|
||||||
|
infodir, filename, GetLastError());
|
||||||
|
}
|
||||||
|
return RET_SYS;
|
||||||
}
|
}
|
||||||
|
|
||||||
fbuf = (s_char *)malloc(256);
|
fbuf = (s_char *)malloc(256);
|
||||||
|
@ -376,49 +380,52 @@ apro(void)
|
||||||
/*
|
/*
|
||||||
* lower case search string into lbp
|
* lower case search string into lbp
|
||||||
*/
|
*/
|
||||||
bp = player->argp[1];
|
name = player->argp[1];
|
||||||
lowerit(lbp, 256, bp);
|
lowerit(lbp, 256, name);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* search
|
* search
|
||||||
*/
|
*/
|
||||||
nf = nhf = nl = nhl = 0;
|
nf = nhf = nl = nhl = 0;
|
||||||
do {
|
do {
|
||||||
strncpy(filename, infodir, sizeof(filename) - 3);
|
if ((fData.dwFileAttributes != (DWORD)-1) &&
|
||||||
strcat(filename, "//");
|
((fData.dwFileAttributes == FILE_ATTRIBUTE_NORMAL) ||
|
||||||
strncat(filename, fData.cFileName,
|
(fData.dwFileAttributes == FILE_ATTRIBUTE_ARCHIVE) ||
|
||||||
sizeof(filename) - 1 - strlen(filename));
|
(fData.dwFileAttributes == FILE_ATTRIBUTE_READONLY))) {
|
||||||
fp = fopen(filename, "r");
|
_snprintf(filename, sizeof(filename), "%s\\%s", infodir,
|
||||||
alreadyhit = 0;
|
fData.cFileName);
|
||||||
nll = nlhl = 0;
|
fp = fopen(filename, "r");
|
||||||
if (fp != NULL) {
|
alreadyhit = 0;
|
||||||
while (fgets(fbuf, 256, fp)) {
|
nll = nlhl = 0;
|
||||||
lowerit(lbuf, 256, fbuf);
|
if (fp != NULL) {
|
||||||
if (strstr(lbuf, lbp)) {
|
while (fgets(fbuf, 256, fp)) {
|
||||||
if (!alreadyhit) {
|
lowerit(lbuf, 256, fbuf);
|
||||||
pr("*** %s ***\n", fData.cFileName);
|
if (strstr(lbuf, lbp)) {
|
||||||
alreadyhit = 1;
|
if (!alreadyhit) {
|
||||||
nhf++;
|
pr("*** %s ***\n", fData.cFileName);
|
||||||
|
alreadyhit = 1;
|
||||||
|
nhf++;
|
||||||
|
}
|
||||||
|
fbuf[74] = '\n';
|
||||||
|
fbuf[75] = 0;
|
||||||
|
pr(" %s", fbuf);
|
||||||
|
nlhl++;
|
||||||
|
/*
|
||||||
|
* break if too many lines
|
||||||
|
*/
|
||||||
|
if ((nhl + nlhl) > lhitlim)
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
fbuf[74] = '\n';
|
nll++;
|
||||||
fbuf[75] = 0;
|
|
||||||
pr(" %s", fbuf);
|
|
||||||
nlhl++;
|
|
||||||
/*
|
|
||||||
* break if too many lines
|
|
||||||
*/
|
|
||||||
if ((nhl + nlhl) > lhitlim)
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
nll++;
|
fclose(fp);
|
||||||
}
|
}
|
||||||
fclose(fp);
|
nhl += nlhl;
|
||||||
|
nl += nll;
|
||||||
|
nf++;
|
||||||
|
if (nhl > lhitlim)
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
nhl += nlhl;
|
|
||||||
nl += nll;
|
|
||||||
nf++;
|
|
||||||
if (nhl > lhitlim)
|
|
||||||
break;
|
|
||||||
} while (FindNextFile(hDir, &fData));
|
} while (FindNextFile(hDir, &fData));
|
||||||
FindClose(hDir);
|
FindClose(hDir);
|
||||||
|
|
||||||
|
@ -430,7 +437,7 @@ apro(void)
|
||||||
pr("Limit of %ld lines exceeded\n", lhitlim);
|
pr("Limit of %ld lines exceeded\n", lhitlim);
|
||||||
}
|
}
|
||||||
pr("Found %s in %ld of %ld files and in %ld of %ld lines\n",
|
pr("Found %s in %ld of %ld files and in %ld of %ld lines\n",
|
||||||
bp, nhf, nf, nhl, nl);
|
name, nhf, nf, nhl, nl);
|
||||||
return RET_OK;
|
return RET_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue