mirror of
https://github.com/nzp-team/fteqw.git
synced 2024-11-30 07:31:13 +00:00
f04e0eba67
git-svn-id: https://svn.code.sf.net/p/fteqw/code/trunk@261 fc73d0e0-1445-4013-8a0c-d673dee63da5
656 lines
15 KiB
C
656 lines
15 KiB
C
#include "quakedef.h"
|
|
|
|
#ifdef RGLQUAKE
|
|
#include "glquake.h" //overkill
|
|
#endif
|
|
|
|
#ifndef _WIN32
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
#define ENV_READ_NAME "FTE_SECURE_CHANNEL_READ"
|
|
#define ENV_WRITE_NAME "FTE_SECURE_CHANNEL_WRITE"
|
|
|
|
#define SECURE_CMD_CHECKMODEL 'c' // q: name:string, model:buffer
|
|
// a: 'y' or 'n'
|
|
|
|
#define SECURE_CMD_GETVERSION 'g' // q: check_line:string, serverinfo:string, userinfo:string
|
|
// a: ok, client_desc:string, crc:ulong
|
|
|
|
#define SECURE_CMD_CHECKVERSION 'v' // q: check_line:string, serverinfo:string, userinfo:string
|
|
// a: ok, crc:ulong
|
|
#define SECURE_CMD_CHECKVERSION2 'r' //SECURE_CMD_CHECKVERSION with the engine description appended on the end.
|
|
//let's my front end work on a variety of engines rathar than just 1
|
|
|
|
#define SECURE_ANSWER_OK 'y'
|
|
#define SECURE_ANSWER_YES 'y'
|
|
#define SECURE_ANSWER_NO 'n'
|
|
#define SECURE_ANSWER_ERROR 'n'
|
|
|
|
cvar_t allow_f_version = {"allow_f_version", "1"};
|
|
cvar_t allow_f_server = {"allow_f_server", "1"};
|
|
cvar_t allow_f_modified = {"allow_f_modified", "1"};
|
|
cvar_t allow_f_skins = {"allow_f_skins", "1"};
|
|
cvar_t auth_validateclients = {"auth_validateclients", "1"};
|
|
|
|
//
|
|
// last f_queries
|
|
//
|
|
typedef struct f_query_s
|
|
{
|
|
char *query;
|
|
char *serverinfo;
|
|
char *c_userinfo[MAX_CLIENTS];
|
|
qboolean c_exist[MAX_CLIENTS];
|
|
|
|
unsigned short crc;
|
|
double timestamp;
|
|
}
|
|
f_query_t;
|
|
|
|
#define F_QUERIES_REMEMBERED 5
|
|
f_query_t f_last_queries[F_QUERIES_REMEMBERED];
|
|
int f_last_query_pos = 0;
|
|
|
|
typedef struct f_modified_s {
|
|
char name[MAX_QPATH];
|
|
qboolean ismodified;
|
|
struct f_modified_s *next;
|
|
} f_modified_t;
|
|
|
|
f_modified_t *f_modified_list;
|
|
qboolean care_f_modified;
|
|
qboolean f_modified_particles;
|
|
|
|
#ifdef _WIN32
|
|
#include "winquake.h"
|
|
|
|
typedef HANDLE qpipe;
|
|
|
|
// write to pipe, returns number of bytes written, or 0 = error
|
|
int Sys_WritePipe(qpipe q_pipe, unsigned char *buf, int buflen)
|
|
{
|
|
DWORD dwBytesWritten;
|
|
BOOL ret;
|
|
|
|
ret = WriteFile(
|
|
q_pipe,
|
|
(LPVOID) buf,
|
|
buflen,
|
|
&dwBytesWritten,
|
|
NULL);
|
|
|
|
if (ret)
|
|
return dwBytesWritten;
|
|
else
|
|
return 0;
|
|
}
|
|
|
|
// read from pipe, returns number of bytes read, or 0 = error
|
|
int Sys_ReadPipe(qpipe q_pipe, unsigned char *buf, int buflen)
|
|
{
|
|
DWORD dwBytesRead;
|
|
BOOL ret;
|
|
|
|
ret = ReadFile(
|
|
q_pipe,
|
|
(LPVOID) buf,
|
|
buflen,
|
|
&dwBytesRead,
|
|
NULL);
|
|
|
|
if (ret)
|
|
return dwBytesRead;
|
|
else
|
|
return 0;
|
|
}
|
|
#else
|
|
typedef int qpipe;
|
|
|
|
int Sys_WritePipe(qpipe q_pipe, unsigned char *buf, int buflen)
|
|
{
|
|
return write(q_pipe, buf, buflen);
|
|
}
|
|
|
|
int Sys_ReadPipe(qpipe q_pipe, unsigned char *buf, int buflen)
|
|
{
|
|
return read(q_pipe, buf, buflen);
|
|
}
|
|
#endif
|
|
|
|
static qpipe f_read, f_write;
|
|
|
|
int SPipe_ReadMemory(qpipe read_pipe, unsigned char *buf, int buflen)
|
|
{
|
|
int completed = 0;
|
|
|
|
while (completed < buflen)
|
|
{
|
|
int read;
|
|
|
|
read = Sys_ReadPipe(read_pipe, buf+completed, buflen-completed);
|
|
|
|
if (read == 0)
|
|
return false;
|
|
|
|
completed += read;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
int SPipe_ReadChar(qpipe read_pipe, char *c)
|
|
{
|
|
return SPipe_ReadMemory(read_pipe, (unsigned char *)c, 1);
|
|
}
|
|
|
|
int SPipe_ReadInt(qpipe read_pipe, int *val)
|
|
{
|
|
return SPipe_ReadMemory(read_pipe, (unsigned char *)val, sizeof(int));
|
|
}
|
|
|
|
int SPipe_ReadUlong(qpipe read_pipe, unsigned long *val)
|
|
{
|
|
return SPipe_ReadMemory(read_pipe, (unsigned char *)val, sizeof(unsigned long));
|
|
}
|
|
|
|
int SPipe_ReadString(qpipe read_pipe, char *buf, int buflen)
|
|
{
|
|
int i;
|
|
int slen;
|
|
if (!SPipe_ReadInt(read_pipe, &slen))
|
|
return false;
|
|
|
|
for (i = 0; i < buflen && i < slen; i++)
|
|
{
|
|
if (!SPipe_ReadChar(read_pipe, buf+i))
|
|
return false;
|
|
}
|
|
buf[i] = '\0';
|
|
for (; i < slen; i++) //now read the extra data that wouldn't fit.
|
|
{
|
|
if (!SPipe_ReadChar(read_pipe, buf+i))
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
int SPipe_WriteMemory(qpipe write_pipe, unsigned char *buf, int buflen)
|
|
{
|
|
int completed = 0;
|
|
|
|
while (completed < buflen)
|
|
{
|
|
int written;
|
|
|
|
written = Sys_WritePipe(write_pipe, buf+completed, buflen-completed);
|
|
|
|
if (written == 0)
|
|
return written;
|
|
|
|
completed += written;
|
|
}
|
|
return completed;
|
|
}
|
|
|
|
int SPipe_WriteChar(qpipe write_pipe, char c)
|
|
{
|
|
int written;
|
|
written = SPipe_WriteMemory(write_pipe, (unsigned char *)(&c), 1);
|
|
|
|
return (written==1);
|
|
}
|
|
|
|
int SPipe_WriteInt(qpipe write_pipe, int val)
|
|
{
|
|
int written;
|
|
written = SPipe_WriteMemory(write_pipe, (unsigned char *)(&val), sizeof(int));
|
|
|
|
return (written==sizeof(int));
|
|
}
|
|
|
|
int SPipe_WriteString(qpipe write_pipe, char *string)
|
|
{
|
|
int i;
|
|
int len = strlen(string);
|
|
if (!SPipe_WriteInt(write_pipe, len))
|
|
return false;
|
|
|
|
for (i = 0; i < len; i++)
|
|
if (!SPipe_WriteMemory(write_pipe, (unsigned char *)(string+i), 1))
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
void CRC_AddBlock (unsigned short *crcvalue, qbyte *start, int count)
|
|
{
|
|
while (count--)
|
|
CRC_ProcessByte(crcvalue, *start++);
|
|
}
|
|
|
|
unsigned short SCRC_GetQueryStateCrc(char *f_query_string)
|
|
{
|
|
unsigned short crc;
|
|
int i;
|
|
char *tmp;
|
|
|
|
CRC_Init(&crc);
|
|
|
|
// add query
|
|
CRC_AddBlock(&crc, f_query_string, strlen(f_query_string));
|
|
|
|
// add snapshot of serverinfo
|
|
tmp = Info_ValueForKey(cl.serverinfo, "deathmatch");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.serverinfo, "teamplay");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.serverinfo, "hostname");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.serverinfo, "*progs");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.serverinfo, "map");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.serverinfo, "spawn");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.serverinfo, "watervis");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.serverinfo, "fraglimit");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.serverinfo, "*gamedir");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.serverinfo, "timelimit");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
|
|
// add snapshot of userinfo for every connected client
|
|
for (i=0; i < MAX_CLIENTS; i++)
|
|
if (cl.players[i].name[0])
|
|
{
|
|
tmp = Info_ValueForKey(cl.players[i].userinfo, "name");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
tmp = Info_ValueForKey(cl.players[i].userinfo, "team");
|
|
CRC_AddBlock(&crc, tmp, strlen(tmp));
|
|
}
|
|
|
|
// done
|
|
return crc;
|
|
}
|
|
|
|
void InitValidation(void)
|
|
{
|
|
char *read, *write;
|
|
read = getenv(ENV_READ_NAME);
|
|
write = getenv(ENV_WRITE_NAME);
|
|
|
|
Cvar_Register(&allow_f_version, "Authentication");
|
|
Cvar_Register(&allow_f_modified, "Authentication");
|
|
Cvar_Register(&allow_f_skins, "Authentication");
|
|
Cvar_Register(&auth_validateclients, "Authentication");
|
|
|
|
if (!read || !write)
|
|
return;
|
|
|
|
f_read = (qpipe)atoi(read);
|
|
f_write = (qpipe)atoi(write);
|
|
|
|
if (!f_read || !f_write)
|
|
{
|
|
f_write = f_read = 0;
|
|
return;
|
|
}
|
|
|
|
}
|
|
|
|
void ValidationThink (void)
|
|
{
|
|
}
|
|
|
|
void ValidationSendRequest (void)
|
|
{
|
|
}
|
|
|
|
|
|
void Validation_FilesModified (void)
|
|
{
|
|
f_modified_t *fm;
|
|
int count=0;
|
|
char buf[1024];
|
|
buf[0] = 0;
|
|
|
|
if (!allow_f_modified.value)
|
|
return;
|
|
|
|
care_f_modified = true;
|
|
|
|
if (f_modified_particles)
|
|
{
|
|
strcat(buf, "modified: particles");
|
|
count++;
|
|
}
|
|
|
|
for (fm = f_modified_list; fm; fm = fm->next)
|
|
{
|
|
if (fm->ismodified)
|
|
{
|
|
char *tmp;
|
|
if (!count)
|
|
strcat(buf, "modified:");
|
|
if (strlen(buf) < 250)
|
|
{
|
|
tmp = fm->name+1;
|
|
while (strchr(tmp, '/'))
|
|
tmp = strchr(tmp, '/')+1;
|
|
strcat(buf, " ");
|
|
strcat(buf, tmp);
|
|
count++;
|
|
}
|
|
else
|
|
{
|
|
strcat(buf, " & more...");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (count == 0)
|
|
strcat(buf, "all models ok");
|
|
|
|
Cbuf_AddText("say ", RESTRICT_LOCAL);
|
|
Cbuf_AddText(buf, RESTRICT_LOCAL);
|
|
Cbuf_AddText("\n", RESTRICT_LOCAL);
|
|
}
|
|
|
|
void Validation_IncludeFile(char *filename, char *file, int filelen)
|
|
{
|
|
char result;
|
|
f_modified_t *fm;
|
|
|
|
for (fm = f_modified_list; fm; fm = fm->next)
|
|
{
|
|
if (!strcmp(fm->name, filename))
|
|
break;
|
|
}
|
|
if (!fm)
|
|
{
|
|
fm = Z_Malloc(sizeof(f_modified_t));
|
|
fm->next = f_modified_list;
|
|
f_modified_list = fm;
|
|
Q_strncpyz(fm->name, filename, sizeof(fm->name));
|
|
}
|
|
|
|
fm->ismodified = true;
|
|
if (f_read && allow_f_modified.value)
|
|
{
|
|
SPipe_WriteChar(f_write, SECURE_CMD_CHECKMODEL);
|
|
SPipe_WriteString(f_write, fm->name);
|
|
SPipe_WriteInt (f_write, filelen);
|
|
SPipe_WriteMemory(f_write, file, filelen);
|
|
|
|
SPipe_ReadChar(f_read, &result);
|
|
|
|
if (result == SECURE_ANSWER_YES)
|
|
fm->ismodified = false;
|
|
}
|
|
if (fm->ismodified && care_f_modified)
|
|
{
|
|
Cbuf_AddText("say previous f_modified response is no longer valid.\n", RESTRICT_LOCAL);
|
|
care_f_modified = false;
|
|
}
|
|
}
|
|
|
|
void Validation_FlushFileList(void)
|
|
{
|
|
f_modified_t *fm;
|
|
while(f_modified_list)
|
|
{
|
|
fm = f_modified_list->next;
|
|
|
|
Z_Free(f_modified_list);
|
|
f_modified_list = fm;
|
|
}
|
|
}
|
|
|
|
void ValidationPrintVersion(char *f_query_string)
|
|
{
|
|
f_query_t *this_query;
|
|
unsigned short query_crc;
|
|
unsigned long crc;
|
|
char answer;
|
|
char name[128];
|
|
char sr[256];
|
|
int i;
|
|
|
|
switch(qrenderer)
|
|
{
|
|
#ifdef RGLQUAKE
|
|
case QR_OPENGL:
|
|
*sr = *"";
|
|
break;
|
|
#endif
|
|
#ifdef SWQUAKE
|
|
case QR_SOFTWARE:
|
|
strcpy(sr, (r_pixbytes==4?"32bpp":"8bpp"));
|
|
break;
|
|
#endif
|
|
default:
|
|
*sr = *"";
|
|
break;
|
|
}
|
|
if (f_read && allow_f_version.value)
|
|
{
|
|
query_crc = SCRC_GetQueryStateCrc(f_query_string);
|
|
|
|
//
|
|
// remember this f_version
|
|
//
|
|
this_query = &f_last_queries[f_last_query_pos++ % F_QUERIES_REMEMBERED];
|
|
this_query->timestamp = realtime;
|
|
this_query->crc = query_crc;
|
|
if (this_query->query)
|
|
BZ_Free(this_query->query);
|
|
this_query->query = BZ_Malloc(strlen(f_query_string)+1);
|
|
strcpy(this_query->query, f_query_string);
|
|
if (this_query->serverinfo)
|
|
BZ_Free(this_query->serverinfo);
|
|
this_query->serverinfo = BZ_Malloc(strlen(cl.serverinfo)+1);
|
|
strcpy(this_query->serverinfo, cl.serverinfo);
|
|
for (i=0; i < MAX_CLIENTS; i++)
|
|
{
|
|
if (this_query->c_userinfo[i])
|
|
{
|
|
BZ_Free(this_query->c_userinfo[i]);
|
|
this_query->c_userinfo[i] = NULL;
|
|
}
|
|
this_query->c_exist[i] = false;
|
|
|
|
if (cl.players[i].name[0])
|
|
{
|
|
this_query->c_exist[i] = true;
|
|
this_query->c_userinfo[i] = BZ_Malloc(strlen(cl.players[i].userinfo)+1);
|
|
strcpy(this_query->c_userinfo[i], cl.players[i].userinfo);
|
|
}
|
|
}
|
|
|
|
//now send the data.
|
|
|
|
SPipe_WriteChar(f_write, SECURE_CMD_GETVERSION);
|
|
SPipe_WriteString(f_write, f_query_string);
|
|
SPipe_WriteString(f_write, cl.serverinfo);
|
|
SPipe_WriteString(f_write, cl.players[cl.playernum[0]].userinfo);
|
|
|
|
// get answer
|
|
SPipe_ReadChar(f_read, &answer);
|
|
SPipe_ReadString(f_read, name, 64);
|
|
SPipe_ReadUlong(f_read, &crc);
|
|
|
|
if (answer == SECURE_ANSWER_OK)
|
|
{
|
|
// reply
|
|
Cbuf_AddText("say ", RESTRICT_LOCAL);
|
|
Cbuf_AddText(name, RESTRICT_LOCAL);
|
|
if (*sr)
|
|
Cbuf_AddText(va("/%s/%s", q_renderername, sr), RESTRICT_LOCAL);//extra info
|
|
else
|
|
Cbuf_AddText(va("/%s", q_renderername), RESTRICT_LOCAL);//extra info
|
|
Cbuf_AddText(" ", RESTRICT_LOCAL);
|
|
Cbuf_AddText(va("%04x", query_crc), RESTRICT_LOCAL);
|
|
Cbuf_AddText(va("%08x", crc), RESTRICT_LOCAL);
|
|
Cbuf_AddText("\n", RESTRICT_LOCAL);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (*sr)
|
|
Cbuf_AddText (va("say "DISTRIBUTION"Quake v%4.3f-%i "PLATFORM"/%s/%s\n", VERSION, build_number(), q_renderername, sr), RESTRICT_RCON);
|
|
else
|
|
Cbuf_AddText (va("say "DISTRIBUTION"Quake v%4.3f-%i "PLATFORM"/%s\n", VERSION, build_number(), q_renderername), RESTRICT_RCON);
|
|
}
|
|
|
|
void Validation_Skins(void)
|
|
{
|
|
extern cvar_t r_fullbrightSkins, r_fb_models;
|
|
int percent = r_fullbrightSkins.value*100;
|
|
if (percent < 0)
|
|
percent = 0;
|
|
if (percent > cls.allow_fbskins*100)
|
|
percent = cls.allow_fbskins*100;
|
|
if (percent)
|
|
Cbuf_AddText(va("say all player skins %i%% fullbright%s\n", percent, r_fb_models.value?" (plus luma)":""), RESTRICT_LOCAL);
|
|
else if (r_fb_models.value)
|
|
Cbuf_AddText("say luma textures only\n", RESTRICT_LOCAL);
|
|
else
|
|
Cbuf_AddText("say Only cheaters use full bright skins\n", RESTRICT_LOCAL);
|
|
}
|
|
|
|
void Validation_Server(void)
|
|
{
|
|
Cbuf_AddText(va("say server is %s\n", NET_AdrToString(cls.netchan.remote_address)), RESTRICT_LOCAL);
|
|
}
|
|
|
|
void Validation_CheckIfResponse(char *text)
|
|
{
|
|
//client name, version type(os-renderer where it matters, os/renderer where renderer doesn't), 12 char hex crc
|
|
int f_query_client;
|
|
int i;
|
|
char *crc;
|
|
char *versionstring;
|
|
|
|
if (!f_read)
|
|
return; //valid or not, we can't check it.
|
|
|
|
if (!auth_validateclients.value)
|
|
return;
|
|
|
|
//do the parsing.
|
|
{
|
|
char *comp;
|
|
int namelen;
|
|
|
|
for (crc = text + strlen(text) - 1; crc > text; crc--)
|
|
if (*crc > ' ')
|
|
break;
|
|
|
|
//find the crc.
|
|
for (i = 0; i < 12; i++)
|
|
{
|
|
if (crc <= text)
|
|
return; //not enough chars.
|
|
if ((*crc < '0' || *crc > '9') && (*crc < 'a' || *crc > 'f'))
|
|
return; //not a hex char.
|
|
crc--;
|
|
}
|
|
|
|
//we now want 3 string seperated tokens, so the first starts at the fourth found ' ' + 1
|
|
i = 4;
|
|
for (comp = crc; ; comp--)
|
|
{
|
|
if (comp < text)
|
|
return;
|
|
if (*comp == ' ')
|
|
{
|
|
i--;
|
|
if (!i)
|
|
break;
|
|
}
|
|
|
|
}
|
|
|
|
versionstring = comp+1;
|
|
if (comp <= text)
|
|
return; //not enough space for the 'name:'
|
|
if (*(comp-1) != ':')
|
|
return; //whoops. not a say.
|
|
|
|
namelen = comp - text-1;
|
|
|
|
for (f_query_client = 0; f_query_client < MAX_CLIENTS; f_query_client++)
|
|
{
|
|
if (strlen(cl.players[f_query_client].name) == namelen)
|
|
if (!strncmp(cl.players[f_query_client].name, text, namelen))
|
|
break;
|
|
}
|
|
if (f_query_client == MAX_CLIENTS)
|
|
return; //looks like a validation, but it's not from a known client.
|
|
|
|
crc++;
|
|
}
|
|
|
|
//now do the validation
|
|
{
|
|
f_query_t *query = NULL;
|
|
int itemp;
|
|
char buffer[10];
|
|
unsigned short query_crc = 0;
|
|
unsigned long user_crc = 0;
|
|
unsigned long auth_crc = 0;
|
|
char auth_answer;
|
|
|
|
//easy lame way to get the crc from hex.
|
|
Q_strncpyS(buffer, crc, 4);
|
|
buffer[4] = '\0';
|
|
itemp = 0;
|
|
sscanf(buffer, "%x", &itemp);
|
|
query_crc = (unsigned long) itemp;
|
|
|
|
Q_strncpyS(buffer, crc+4, 8);
|
|
buffer[8] = '\0';
|
|
itemp = 0;
|
|
sscanf(buffer, "%x", &itemp);
|
|
user_crc = (unsigned long) itemp;
|
|
|
|
//
|
|
// find that query
|
|
//
|
|
for (i=f_last_query_pos; i > f_last_query_pos-F_QUERIES_REMEMBERED; i--)
|
|
{
|
|
if (query_crc == f_last_queries[i % F_QUERIES_REMEMBERED].crc &&
|
|
realtime - 5 < f_last_queries[i % F_QUERIES_REMEMBERED].timestamp)
|
|
{
|
|
query = &f_last_queries[i % F_QUERIES_REMEMBERED];
|
|
}
|
|
}
|
|
|
|
if (query == NULL)
|
|
return; // reply to unknown query
|
|
|
|
if (!query->c_exist[f_query_client])
|
|
return; // should never happen
|
|
|
|
// write request
|
|
SPipe_WriteChar(f_write, SECURE_CMD_CHECKVERSION2);
|
|
SPipe_WriteString(f_write, query->query);
|
|
SPipe_WriteString(f_write, query->serverinfo);
|
|
SPipe_WriteString(f_write, query->c_userinfo[f_query_client]);
|
|
SPipe_WriteString(f_write, versionstring);
|
|
|
|
// get answer
|
|
SPipe_ReadChar(f_read, &auth_answer);
|
|
SPipe_ReadUlong(f_read, &auth_crc);
|
|
|
|
if (auth_answer == SECURE_ANSWER_YES && auth_crc == user_crc)
|
|
{
|
|
Con_Printf("Authentication Successful.\n");
|
|
}
|
|
else
|
|
Con_Printf("^1^bAUTHENTICATION FAILED.\n");
|
|
}
|
|
}
|