Add ERP Local Agent secure self update framework

This commit is contained in:
A R R R Associates
2026-08-09 12:45:44 +05:30
parent b374aadf44
commit f79862203b
25 changed files with 1011 additions and 583 deletions
+26 -555
View File
@@ -1,484 +1,16 @@
from __future__ import annotations
import base64
import io
from pathlib import Path
import zipfile
# Embedded LSA4 tunnel-enabled Task Scheduler local storage agent package.
# Uses Windows Task Scheduler instead of pywin32 Windows Service.
# This is used by the ERP Branch Storage Dashboard to generate branch-specific
# pre-configured agent ZIP files with .env already filled.
LSA4_ZIP_B64 = """
UEsDBBQAAAAIAKqIuFw8Po1A0QAAAD4BAAAMABwALmVudi5leGFtcGxlVVQJAAPQLxNq0C8TanV4CwABBAAAAAAEAAAAAG2MwWoCMRBA7/mX1GwLtSzkMLuZ
1oWY2Mms7UEI0gbdg1Vi/H9XrLQHGZjDvHkPaREbCBh7snpbyqGeTKrH6YMap6pflFLCeYOxHZduCFw7k4E9wRtKpaorDNgSsj6sjyXJ/U+SZdgleUxfORW5
TTmJXyWS96xNvVrB6Xsor0PehbLP600SC29t7BwjLcFeit6ZoJ+UMP7DWQ8mEr73GPju0wyBuEG4Q5+VuJnczdH3/B/N4XMMM3U4dgT3zqGN6KCxaHTJp3S7
0cVx2P7ZlRJnUEsDBBQAAAAIAKqIuFxSLOFFWAIAADoEAAAKABwAUkVBRE1FLnR4dFVUCQAD0C8TatAvE2p1eAsAAQQAAAAABAAAAAB9U8tu2zAQvOsrFsjN
gJjmcQrQg5OobQDHMSQVQYsAFkWuLcI0VxXJPP6+Sykx0CCpDgZlDWdnZ2ePYB61CfDNDHuoAg1yizDfoguQQy39DirVoY4WBygYaMhlWd0ZD71UuwQeovMQ
OgRLSlrwrxxy5IjeuC3cG6fpyb/nM84HlBpoA/3Lk3FnpwdkhcOjUSiy7OiIX0LsgdyBW9G+jwGH7ERA8RwGqcKo4PfNCgKBhI15Rg0bsprL+Kg6kB6a4uJh
bDb1+trq2Ol6XpZl/uUkr+q7cv69aER2KqA02y7kyhq1gyZJldauA3ew9m8diFaGBqTToDoijzCbldGlWlLvjTOepXGd2UxkZwJqVpjug5N7BHaw+VBNI+Am
TK7KcDCEyw/JBeauflV1cSuycwFXHSZ1lrb+YTRc8HFSlPwoyhVcDtJx/2+jvZa+a0kOyZ0B7paLm2WRyEP0k9m30kUeI1u8Zxqf5XyVS4OjpwtoRhkfuTDi
qD/AqP8UlWpNVHz4BFXinh6RUdH9x3oGXmMbt6zWebJjFvkO/65HN9Y6fZ2g2ZIOKdPYo9Po1Esaw4B/ohk4L8axa/wHTjlnPxbV/Bzq6BxauCWNWf7u+XcV
iGl5ag4ohpYiT+Ee24rUDjmfEwvH81iTinuW549fA52Pao8niBjHpgk9m8m+46hsopNKofeJJI23nUa7uhK8vtw9p835TUp8MNby7jGFjIx0wSgZmOdHXa8q
4N57MlxfpNWC+udyWSzWxXJ+uSiuv26k5SRzCT5YaLm19MJwXkkORk/W8k6L7C9QSwMEFAAAAAgAqoi4XJHTLtOTAwAAOQcAACMAHABSRUFETUVfTFNBMV9M
T0NBTF9TVE9SQUdFX0FHRU5ULnR4dFVUCQADzy8Tas8vE2p1eAsAAQQAAAAABAAAAACNVe1uGzcQ/M+n2AfI3fkjTgsjDqDKahLAsQSd0vSHAYG6W+mYnMgL
P2wrQd+9Q95Jllu0iQQQFLk75C5nRqNQK0+/K7ulG1PJlkpvrNwwjTasPWV0U45O6cMfMyFmwXbGscj6j1g0ypHzUteyNZppZaWumkxprLUt1yQTRGW05so7
8oZ8A+B04mQ+o+vyPLsuL4CRjsz6eNZ1Z5T2LhfvPXWhbR11WFR6Q7WpwjYGDSn02awcra3ZJuiqNaGO0C9SAGNLtRiVTtv9BalNdQ4IYm3amu0LktUXbR5w
7Q0Synejs4tX1EjXFBGCnPqGfMTEKtIBKJvWocWuo1hEbR50a2RNlr8Gdqh3taPQxSXcXAyr6Ep/fH+xI8Rc9A3tsBQrqw32tfHA61pZMRlLW1Or9Q5H71L/
Oms+o7MJC+kl+9D1ryNOcxqbboeyAdmXuO//0IV9Byuz7YJnWzi292xzcYZUy9IzSZrtfGM03SvrAy7NGjOj4wtcCiLq+u1sS/fYojyOcT1N7srKqs67O1l5
dQ88cZ7T+54cqUnKckRyPZTqaGAOZfbZfu4fvXg5FJQDOedHue3wKqgo/k5vwWBVQkJnlr+Nysny4/zmqvG+uyyK07Nf8hN8Ty9/PTk5ia3cmWCf+EKIjbm3
0+vJcozh6nUiVWLpwDlH2ZuDOm5Nze7NIaWcjOeTxdVryCDzassEJrOV8bkdV5Z9Ci0X0/no7WQ5n04XV9eXd0kJUXoDqrjIaR40GV3xP/orY+hyeLNlEkq+
leB1lsVo8arPhNa80sEE1+5+EkGIWORBdBQc13uJH33EbFouqNgL0BXPRFs0LK1fgTXi7WRB/x0X9VoMav6Z0O8Yl6r+q9jL6wf3eJ4EKf3gkD1stlft4XL/
f86/874Ps3Rwr3tIUq7Z74Z2iow+Ooj6zyyyJxtjSMwdfpeJKIROQqwwvyyR7eBhRrfwEx2FfMyjFCe18jAoB9m0EmJjuIhvBmOMz7viNaDowSIONpoQY+a4
F//B8Ia4Iy+MxcfIT0hFXGs2ycjzIs6KnkaYwn5u+RF2Dctkd8wfgT+QM3pQ0DXYCYfxvSmBbAYO80lp9BIXgPsouFyxd4FoRUg971NlXZPpvDIaNuQD/lHa
oo2FOg+v2qKoFP3yKfrdh9G4cGqjpQ+Wi5oT/Eql103BF0/BLqxcciscQGtOKVQ1XH1x6Ym+BuMlRILuVENL/gZQSwMECgAAAAAAq4i4XAAAAAAAAAAAAAAA
ABQAHABhdWRpdF9zdG9yYWdlX2FnZW50L1VUCQAD0S8TatEvE2p1eAsAAQQAAAAABAAAAABQSwMECgAAAAAAqoi4XGtoY4klAAAAJQAAAB8AHABhdWRpdF9z
dG9yYWdlX2FnZW50L19faW5pdF9fLnB5VVQJAAPPLxNq0S8TanV4CwABBAAAAAAEAAAAAF9fdmVyc2lvbl9fID0gIjAuMy4wLXRhc2stc2NoZWR1bGVyIgpQ
SwMEFAAAAAgAqoi4XGFbMtTgAgAA8AsAAB0AHABhdWRpdF9zdG9yYWdlX2FnZW50L2NsaWVudC5weVVUCQADzy8TatEvE2p1eAsAAQQAAAAABAAAAADlVktv
nDAQvvMrLE4gsRCpt5W2UhX10EsVtb1FkeWFYdcJ2NQ2TVaU/96xebNRu22lRGo57MK8PPN9MwO5kiWhNK9NrYBSwstKKkOYENIww6XQnpdbm4qZY8H3g8EN
PnYKc6q4OAzyd+IUkQ8GFNsX4PVCBV9r0GYIFadS5HxyOYAw107keV5aMK3J+0831wVH+dYjeGWQY5JccENpoKHII9LF2M69w87YXtZmOGbX2y6VGrTG8lA7
ZBd/7kRB+KxlfASWgdJxXWXMQNAFHaShNyVaq6JP0oK2JdqokGze2v8pQwWIuCC538xyjUFVdM802BhtY91bf4qMZymzB2bG8KdCsmxLMp6aWwwfWfzv3GFL
0fxcXSGrgIUvqqukNsFoNRbvivGTTKZ1iUDrRBup2AE2zOKejBn5YbRwvtdS7Pr8lhrDS5C12c3L7imgvY5qQEWmJ8fwLP9YMY445VJRjZ1a6yBcYzua2mSC
kPB8EmF8gwUQKBCKxu9C+Fviywe/nRCvQGTY3bSvmt7LvXbgO4wLrs3tEugLkD7AHwBtD076bNZYvwCi2PHMjcoC0VGNyHLNBbqJFAJrHDlwZhM548Xq11xZ
mUPGt5X6Ebm9m41UJh+F7aQ5D/0M4B3lOAIcyfzuRu2vGMh/QUHTndcmQ0prMjADYOXui6rhtft+wo+lD0I+FpAhdBdA+HKb5WK4sYJ/fMWMPT68j15lzwxZ
bIYs/o+lM1S7Xjx15ShZc0NzXkA/PYNoNUHWgnbvf/utFBF4Mor91kA9cnOc4sQSmcBM935ImCZHJrIClqVaW42INb69w1YLJm/BSoh6r4j4rKoKnrovvESm
BsymW1x+2C5C9hw0D3ByHzLBN1bUEBIkjKAsIu4ZC+/qi7mBUneD0Gs0wU9J8lEKaFe8XLoulr37k5Vx3rzNxE6bdGSuG3kEbud+z5UWgp1rrzPVMAQlewou
GQTy5upqdfwLr50fUEsDBBQAAAAIAKqIuFxm9F9AyQQAAOYNAAAdABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9jb25maWcucHlVVAkAA9AvE2rRLxNqdXgLAAEE
AAAAAAQAAAAAvVZRc+I2EH7nV7h+sqeJk5vr3AMztEfA12OGmNSY681kMhqBRaKekVxJDkkp/70r2dgCm+StPGB79e1qd7/VateCbxyE1oUqBEHIoZucC+Vg
xrjCinIme71KxmVvrdEpVniVYSmJPMBrUYkoRJbRZZBjIckBAjLCVjwlJSTH6gkwh9U7+Kysc0XY80GecZyiUtTr9T7X+3iA/YewQSIK4veMyBk+EqZGnK3p
Y7/nwI+IHC2xJAj27jtSCSNl4APSjpyIJFkJohqhVFzgR4IE5yA1DmoxuIKZQjQ1SOdfJ+KMOAPzMIClwGz19AYg51mGKFNEPONMb8tZKvsOSAD18dpgUr5l
JnZB/i6IVO/hnwgWaknweeCnEniwp+iG8EKdQW3wC+ysBCXNRmX0BWMkQ5CDZUYgwiXnGSxqGux1oa0ysmrZ/3DdM7jPueA5Eeq1jJasdQQpEdKTJFv7zuWv
TkpX6h4yeKHT+FAyWkYApcqcXS3QP/f7ZQQkXo7gz4XEg5GgJvqiEzo3fB+ByxI4gS8kEZemtgDrDouUqi9UbOZleZiFq+vgl+DabRT356Ks8gMF2QQK4TXR
6XqFPBmf7PoNBMBo7rlXrl+D6drgA6mAfbml6slzn5TKZf8KYP2jOLYSVbbdrTQA52ejfQ/n8kjtodYj2bkd3tug037bvCTnbOhHvQQlK15BWPcQb+fW7Lbp
dlyLzQ6C9/5pMa3dXbX1/irlq2IDpMqrqgNcYsNxSd1vO+PM3oV2pAlFj0QhfQo8hjdlS7nQTOMiU+XxMBzrlzJUgbcQCZcBKEJXM2qlP5BrvUhl2TG40MY8
EPlByb3vDCC5busoVNv1LNGpZpDxLRFggcLJcT+4kCMFZ1Y/X4k0D/3Hmbu3A4ND2xkXyE1Y8PxfolLitbWufdNmylb/siK5cr7hrCChELALllpoaWEKhRUX
THc+A/HW7oRBp6SptkUeiXDWoLjTvu/7zg6M/yT2ru+YewmMVZkxfXll7hkPYkVrmpGOXm8S1LqTIB21Tu2bdcvVFqu4jo6IjfN7rTvuiAE3jO/QzXAeokU8
1dy6fquH1EfmWDOajUM0gr9KreLp9KrsUJqHozhMOtTsyxSdFos7T2bx8PcQxbNZl3J95R5rJWE0jBI0GYOKJR4uxpMEfZnEt+USWGtKDfhtX9THZm/iYTT6
Wuu2VMsLkkpJ2SNo3j8ceIWJ6XjkqHmr0AHOc8LSE2p8W78ZTs4qN+y0NasZ5m3diqQj7VN+zps44qq2UaHePG7ubZU0PYNQQVKnPESFMIMmXK5wY+ikB39x
yrzKpF8Vuu0hpF3PY96p135AXnLM0kLqXhcIInn2TDpKMNj8SKnwYEDVjd5MkRdwwikMRvxHNVTa7dQ6xl5zfVlcD+yPZg6o6Rx0DCMWY4PO8cP2eGB/NJD6
aAzqt2axLvBB/dYsdk6hg7rru3ez6RRNoiSMvw2numZm0XgO7Hy89hsj746plsHx7M9oOhuOURz+sQjnybvGz8+0ltWv4TBObsJhp7lPtrkzk69l6+BYMrkN
Z4vknCFrOLaUb4ffIbIknoQmEAt/PDMPmonBTRZRFE4R9LGbaaj7jam8lmZrmra2rUzE2tUoHNlOfzg47ff+A1BLAwQUAAAACACqiLhciumkADYCAABCCQAA
GQAcAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvZGIucHlVVAkAA88vE2rRLxNqdXgLAAEEAAAAAAQAAAAA7VVLb5wwEL7zK0Z7AolyaNUeVkorkrrVqnR3xZIqOVle
MI0bsDe20ab59TXmXTZK1UOiSrUQ0ni+mfnmBbkUJWCcV7qSFGNg5UFIDYRzoYlmgivHae/UXcE0fePktcmB6JuC7Tv81oiNIiOaalbSTtPJPtTvB8Gp4zhp
QZSCSKSk+Hi+dMCcjOaGB+NMY+wqWuQ+ZHtch1la714Ds9DmPjgQSbkOytuMSbcR1FkiKxOL3jOlsbi1otdb1n6D1hzOOkdTteXgek7PKhWc01RbUiMW9bXx
0VYlGKO6EN4EHEhxxDlJtZA/R4axOPYwSU0buEUP8RtCNjq8eg9rU8KBxZGZTGzMjoAHRFkPA6hnQO9pWmnqTjT1WSwWs7uLGIUJgiQ8jxCsPsF6kwC6Wu2S
HRykSKlSNMPKZEO+U/xD7BXM/dbHqDDLIEFXCWzj1dcwvoYv6No/CS7qmWj6Yw3qoOvLKDqNVjfk9dt3f4LMWUGxYg8UVusEfUbxE/ghQ6Kn/mdw78lqes/R
iUwceSFIhiW9q6jSj7WjVf9vyejYlqSiLKebL2kq5GTG2y9TM9JLUFr6o/q0F00JWqHPcgmM6+fd4NV6h+IENjHEaBuFF6iu9OaR7Z1Zu02W4wS73EZp+ZO+
zAv/LYwu0Q7cDz4Mz8n+zLvumgq2LLzTNExJ3Z6KwXR/m4CLo9v9cIJKp17AlMiFLImpr+f/9Sz8vmXtQAxL9e8PxexDMu/LkO5LT8fA5KUm5BdQSwMEFAAA
AAgAqoi4XDIlztSHAQAAPwMAAB0AHABhdWRpdF9zdG9yYWdlX2FnZW50L2xvZ2dlci5weVVUCQADzy8TatEvE2p1eAsAAQQAAAAABAAAAACVUttKxDAQfc9X
hMJCCmtdBEGE+qCwKCyrqO8httNu2DYpydQL+PFOs8naBV8spO3MnDNnLmmc7bmUzYijAym57gfrkCtjLCrU1njGoq+zbatNy5qJEo1ip0zdgfOJ+Bxopl3r
Du4PsQNhULjr9FvCPZHJGKuh4R5wHOSUEJx4Ux5krd11QPBvvrUGeBk+OT+7OQpvAv6acXqctUiYxOXWBXZRfdQiDwhi+RAqD+Bznk2u7CRY9Ht6i0E5MOjL
VzfCksOn9ijtPpg5SwTSplypmBbwUI/I1FhrlB6tUy1IOgazfMYqqN0NvEMnEvlhu348QaSZFlUHyoko2ljXK8QT3XXyiWwhlK9Q95B7mtpCdJOGUUd79tuD
91RY7rOYuqI12w5miV/QgerjAuMMI2pq4Ff3WNVJB6quEzeyUhN0LWTsj+T+uC3iuCraURhfQZ5syXv1efuF4MsLuVqtprOkjVf7cbizo8HyknZlKltTujIb
sTm7inOfa/6z+Dk1duDotjoTsewHUEsDBBQAAAAIAKqIuFzrTm6iUQIAAH0FAAAbABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9tYWluLnB5VVQJAAPQLxNq0S8T
anV4CwABBAAAAAAEAAAAAH1U22rcMBB991cMhoIXsm77WnAhbVMoLSUkeRdaa+yIypLRJY3/vqOL7c2tfvHunDNnLjryYM0EjA3BB4uMgZxmYz1wrY3nXhrt
qmqN2XHm1mE1xJyZ+3slT2vCNf1diW6hpERqeyVR+5V0dXP9NQVW1OhBjiuqDBcshwouNvlfpufq25cSV2Yc0a6YQx9mlmOF4Bbdr/CtN5aPeDnudX3QGtVr
hLuEbBNHHWmqqhI4wClIJVhagW0OcPy8baS9tGOYKP06gZ8qoCcToXuL1Qh0vZVzXHJXXwYhPXyXdsqzrl1BaguOcMfdH7jt71EERbI3sVFbH85KtVwIxkuN
pj4eUT/UF3CPau7qeD7gDbQUhEEqJISG4kH57rfR+F8ho/vI533u1VFryLwNuMlTO0AqkBbfL71CspAAfJS+9GjplKwuFcpGJy513qTU/tnWnm47YdSSI6Q0
mV6xTVfg4oruiSGaOHjb/xXNIbO8XXKl+BQDdufea6JkXNNho5EPu9WCZ4LwHmrBPa/jDx7PiRxb72nF/N3u+yaX2CkpixjnHiyki5JP53S6KMPtiXJI62jj
0ezzbJKtDZpFrNlTUFFS1i43gKHmJ4XimUB2fZRoXt6NJukfUoGBfPAQj+e8iHuzn42+4cUUH1IAH3ucPfzE5WS4FT+0R2vD7He5vIRW6sE0db4Y5MV5RgGn
BYLbLsRb2lfpRSamIWPshTSuhFV/4DIuCN458jqhL/Q/kpdpr4xpPsUPaNdBzVh0NmN11rdcOoTbxXmcruhGNNn3h+ofUEsDBBQAAAAIAKqIuFxT7nvCSQMA
AOQHAAAeABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9zdG9yYWdlLnB5VVQJAAPPLxNq0S8TanV4CwABBAAAAAAEAAAAAI1VTY/bNhC961dMeaKyNncTtAFiwAFS
YFv0kDRIcupmS9AWZROWSIGk1rtBfnxnSMkr2QEaHWzNJ/neG1K1dy1IWfex91pKMG3nfARlrYsqGmdDUQy+vQr7xmxG04XxzeuipjadipQw9viIZg7Ep87Y
3ej/3Vjln/76uyjk53d/3MrPt3++v/3wBdbYSGxd25lGc8/u/n23/Ectv90s3wgJy/srVhZFUekagrImmm9aBr1rtY38QTW9XkGIfgG1apqN2h6SiT2Zibpl
JSzfkmNVAD4pH2O5EJwHxkqBYdPxUnjdNWqrObtmC2CSTTxfvw6uWZsZDBH6DceURQ7PM9P/sBADMTbyGtm3cNrNiOGEt9bS6wb1eNCSWOZeHdNLRvkdPjir
n7HLGim0qs2cJOwkRgbPGPuU11OpM4ydk34L6Lx+QFJJMHJA9OpB+6AacH0MptLY03m1w0LnosB2qa2p4bSp5KCnUz4GxH13f3LViI/cYGzaFMcdnuBcUH3N
ylKkLs9Nh9XGJpxRokg/gpXzPHq2jtD0ehZIPYXqOm0rfjFQFC3L4my1sz0MqiUQL1J8pmYGd975QiLcduW2PQXFxljEO6judXAN6t3bSntJXPOB+GSs0gIL
mM1Fdp4JHpXf6UjjPq2H63kpUZ8W5AMKylnDtOQsA1lxQVAtndrW2TSZd0lPzC4XNHw8r17el/DLGk6xZya9MkHDpx41avWt985znM+6DzSA0cHR4wG+9lpV
P5y/4QTlVWhSiMf2UBnPsxHWX3yPLOtHE6J0h2TOhMq141nbq1e/vU7q8DNGT9dHZXY6EDnDjShy0UDL0eChSaw4HC7O/AZvHxUw2VaNXs0Ownbf2wMNMWL0
vFHtplKrIVMQZv7y5tWv8ALoDwndsPMBz3sRfVepqHnqNwM3xPf6Mb/xcbwSrxIxadXK6DLibK5OV/QCKqxBg74EEypi3zX6ztiYNL7/GVoifk8aDN5kq+3w
fdJcEG0y9HVtHvnUn11whaebzhibkIxNBo6PA8c4Ic/0HPcICUjuOWWZ9DSMCPaS5vOLBr+Euebyatlg8WHmxR2IRO1Ui/8Va3wySVdrwLthGiek4804IWc+
xlS7+JHg/wFQSwMEFAAAAAgAqoi4XHs9ObNfBQAA0w4AACcAHABhdWRpdF9zdG9yYWdlX2FnZW50L3N0b3JhZ2VfaWRlbnRpdHkucHlVVAkAA88vE2rRLxNq
dXgLAAEEAAAAAAQAAAAAjVZdb9s2FH3Xr7hVX+TNlbE9DS40LNtSwMDWFl32MASBQEuUzVomBZJK4mX577uXlKgPO0H7kNrk5bnnfp3rSqsj5HnV2lbzPAdx
bJS2wKRUllmhpImi7uyrUTKqyL5klhU1M4ab8MCUorDL4cpbNszua7HtrT7jV39hT42Qu/78Sp6iaPP79cebzc0/+YfNH9cfr/68hgzilLWlsLmxSrMdz6Uq
eUpE4iiKfgnOEsT8l8vsRrd8Ebkj+Ms/2ZRcWmFP6wjwH9dNvmWG562u12CsdqeEmhf4ZziyXDJpc1G6I/gPPirJ3c1WM1nsL930JLVSdkDCEwfkaeT3XBtM
6xqEtBjgDxhHySvAyPSR1cLw5J7VLTLBnCzg3c+E47lrjjWS9N2bgNIQx4sUD0STLFLtP8SreHEGmquGisnqC+hdCN6JR85e9DJmEu5dBrzLECdVPplmhKrv
nNKHWUyDHazgrBM68G0r6jKkMimUrMTOIV4sdgc+u0vc3bwZslEBPG46vl4sw6vQLBeehLuRfeik7FI5dhzHDHPtAZYQB/N46fK6GEGF1vs2qGB+AWqc8cyE
V+n4PNXcqPqeJ/3Dvq+4NKQWve2sIEv4bgmsrtVDLqSwwtFcw1apGhvLjei0CeI4/k1zZvkKO0rgUGNTSR5aCSpR4zcJds/B8JoXlpd9AOCIRg7nZi8MNFpZ
tDDO2mcg2FaqLrkGJ0CsKJwDJHqCLScx0rw1iLw9AXN4pagqrtEGrr98dnVfeUAMT5awE/d84oZIG8u2NZ+xI7CNhVKhPeoqmIL5aB72Co2PrNgLyd87N8aK
ukYqRyakR68EVhdYa/dKYzocWoVTxyX+LYg4OWaFRT6OpWNHZ74eWKrSEYEGg/dc0z7xflDoMoNXWyAYpsdDKXTSMMqMcZK7BP4ojM3VoVNgsiUBQNCpIBCA
v+aPjS9k9sJY+5qKymWMHqfOiUkW69DF3e1ZswUDR5rhEXxpEf3Ir7VWOpncu3zGnUrMu87AURhDSWYWnojGcwqb4OlSl7lsVgLlOI0njhbhmwvnAavJc8sf
bUIbLS3bY2MSv0iTPj2LJXY+Ucp+xI9corggmSxubfXup3hA7LSOgH3irD4NaXCZoyAyt8XTWrHSJI4FDl7pSZyB94UqeIMj4P5DpcFVT2cD+IX8vpLOVpJH
GpJVobRuG1wMPq/xwk8mgvsQij0vDgY5PwVf8ViU43XooolYDyoXB0Ee24bDkeEguyPDcDgyHER1ZBgOveGzp499XGMtzRqwVewtquwdBnN7Fyb4wE/LAJL7
dYo65+NOsTuOk27vq5j3S7o/SFH6EwQb2uEtfHJS17DigGXALmYnNyp7hiIR4loF4ilc0RDBtmbyEIBHeM4nCpJCsYS22WlWklZyfLTECbYgOf608aNIoVs0
wOCVPnVP0/HUIlmK9Gmy7ka5fXYaRoSnQU8nm9wI2fIxMu2yWaJe/p0Eb7LuwaQGLz848+8rnLKm4bLEvn/CyJ7XgXX2NOXyRj8PFafLsV+8jAfZG9rntUmb
8IndBpxtOzxhNc3cCdtQHrBoWEM2W279m9GSS2GqXvHfndwFMZnpXtdrlD3cGtyuij2TO/9mqo005eh17uF7iN/jL/6vSsgkRD/0dJeat/ArOqpoS/bS7Juo
441Kw+vSuAbSvEIqe+i2WL8GSfc6hSGGtIM+sNr4Pmob+gVCZ50Q+2gX06kNwzrX6/OxxVp2mGFO6WFCP8mW1GiOqgekjp/cTRuuw7lFDJIS92bakSEgWsTR
BffxeL37CQgyNvm5Hs2dTl/ewQsPo9e5dKcD/Cu7sPP8DStwvP7+B1BLAwQUAAAACACqiLhcnDkQJcEGAAB6GgAAGwAcAGF1ZGl0X3N0b3JhZ2VfYWdlbnQv
c3luYy5weVVUCQAD0C8TatEvE2p1eAsAAQQAAAAABAAAAADVWN2P1DYQf9+/wl0JKUEhXCn0YdWtRO9DVIIDAX0CFHkTZ9clG29th2M53f/eGTtO4sR7t0B5
6D3cXcbj+fjNePxLSim2JMvKRjeSZRnh252QmtC6FppqLmo1m7UytWk0r9yT5ls2K3F3QTXDJ7fXPSdG54uoW70d1ZuKr5zaK3i0C3q/4/XayZ/W+5mVp3nF
Wa3dwvnrV6dG4FZFXfJ+2xpWTo2oXS86V89FTquzP1q50kLSdReuZEpUn1jW1AWTmRRCJ0TRkmWSVYAArGDgINvQR09+zUpeMVSoueZfWKbYeguOE3IluYZH
LRndZloYPd9hxgvQ5HrvPLNaIejj5dlslldUKfLGLpjMFjMCPwUroVgcfGdZpFhVJsSisBjmD0KD06KHLCHFauFwSEgl1msmY2sUf9CWA3TZ2hwt2losW9v+
IkC9BA++0DqBBfvPaJEqnW0YlXrFKJo9SU98DQeHQRIUDqAV2WDjkO+U16WI5i2OBGtLuiJ8YpKXnBULck/Nk4DPeNZhLps6K4VksMfAHpMHv5NL6OzFrW4h
O2zsp03BNbngcmsr4Apra0bAMKlFwZZ9HDanFKVZDr/67K42iMZb2Qxcd+4xTFHnLIq9NTyGqaoY20Vb+jl64jvZiaqCltJMfqIV9DOICxWPkjdWb8s829L9
ivUVjUYF2UmRM6W68v0tVuqQTiGu6krQAk7gPw1TGhX79h87sscAQMzZgqyEqKBXLmilWCDUWlzBqsEDfw0C4CUsamsGpl9hVB8Ee/U3D75OPsHQL5BkMGHr
TqTl3l/XMG+rhDSKFZCNZNjyduKmBVcfs0YBbNHQs4MS29ov+I7uET+wcO3J8WeuYK43ar4g85eXz/+8PJ8nIZ3eNGjCVDvsObDd5JKt9pqhH5vZVAtT7ZRM
3lMdBKLTwYeADsVTlMHRVHBdYV4n6eP0JJSW1cTKZ43OQdVdVXDSriJ3W6WwFqdcCWiGLXayb+lmeu7sSOxbIWoLEE9VJ3MPPAe0hnPkWaet0IuBIVuvlvfS
R+W87ZWHJPr55NFjcv8++SXu3bLPOdvBzWn+ADqEKpQFRkfrkTnNoduSwsxxcxI0BqcxeKwPzIlJy6MydvkAwh2rCxiZh8bE9+RzKpqqsIec6XxDWlfE0QF0
NcxxaLI9vHePtAxv/Kwtf4SSO8DyN9hZZiOpuNLvCp7rd3D8EqRFHz4EUG0Hl9lz28CZ9tWFAMoDCYeQiFQMOFSstjn0aOBlBSLC64DLSYU7x1kgdzTtw3xU
eW8p8Zs+gbZp8V+4VwmTUkh7wYIkXTMdzXkxj4lNxwrgnwyF4yYPBt+VCqaIV6RAjaxhaPW7fQ/r2m7jamTOVJdyxcjrpsapdY7ZRaWX/5YrZVg1HNxrENzM
475/heRrXsOMRp5T0y0bxjZZHIXqi0sT+rWN9SZd8XreufEYdCbp1dBNhVTIJ9kjR6O1sFkcIBO2Hk08J9Oce4MFkAxYMi23DLwQHLz8Ej+WeHbLcTtrWc3o
pGF3akFcawKIyTCeYdZqB69jbDQxO7I0OllgaHBqIeVMwfsK2OZrsA5GQi8skXOSGsi8ODpjh9jFgFmcvnzx6vn52/Oz0S08b1+iNlRt8Po1sYxUTKx3KmAy
SAm6xHydUHNZHuMXLJVsV1HgtvP376EA84fzeBwxwAo8Bfjdt1GGG78l2qrR/CPshwG1ZoHCJWRCINrXLIg3F9IvNmbVFjw5MkUHbI/fMe9POFZysd1VTPeT
FTebv6ayS6+PJ20XuAynZP9Y+uA2ZAc4ROg1wu39EUTC+esC+yY2MYnaZwid9BgoQ+SiW/tahtFv/E6aMcap5xp9cj7haOVIOg5EcQTzGKPjvP23FORslJzj
Ie3jiIu0Up8TeML24XZeMknNK/URBKX3Yu6/rwlq2CYDM8eTlgliHnNphR57CdEKL76D1GKURZheDDiRp3+AF3k6Y240rkx23WN0G1k6mtVMyYxNvv1o9j1c
ZnT+e7vQ79yMU2K+XHlLXFkiEYcqfwErl0KbieDK/9riAUfEhAx9Y7/D4NAwdqs9tEHvweuE7oIxXxm7IJCIRDH8MWs9zXPUZ/ApN+q3DW+HHcshJENDxm0w
pDDhBmjXhgD6FvH7El7UnjSGaXLFJKD607IN1UmOof7PnkJUeHaAf8CltGL4vZI0O+y+tPO/vPZ83hCa64ZWy2vr0AN3Osr/2jkCa2qFo3k8zHHAYft4882Q
gTDQWtIAkfw/scQwwbPATyazY9khWBILx0HON53yJlBn6geRv+mF1lSwcXSnhVjgMMsAE/wXUEsDBBQAAAAIAKqIuFwwclIdhwMAAPUKAAAdABwAYXVkaXRf
c3RvcmFnZV9hZ2VudC90dW5uZWwucHlVVAkAA9AvE2rRLxNqdXgLAAEEAAAAAAQAAAAAlVZNz5s4EL7zKyyklcwqS/fzgpSVeuihqtRLe6sqy4Ehr7vETm3T
vOlq//vO2EAwkLTlkID9zMwzn3ZrzYkJ0fa+tyAEU6ezsZ5JrY2XXhntsmxcc1ddK5O1JNJID16dYBQYv3eMfr8aDRHnr2eljyPqpb5O6i5wcKb+BzxaCNCS
9I/Id95YeYSXR9A+y7K6k84li+97raGrMoZPAy36oLTyQnAHXbtjkjBVIlFEMD2EKQOE7SM03aqNbtVx3Bs+U0hnjkewEyR+ZgETAhVI2V6L1lj4AjbwKtgv
f7O3GJxqS1mpdGt4/s5L6yloPrjITqYBhlqYxpf9Ty7fzUmWtCpq/CkmnZcn1QF7b/uZHXq8vaYLge1FKh81ohq0WHthdA28SKDwXMPZszdwPRhpm9fag7X9
2a8VWqkcbMm+Cn9YUxghWluLzoMBI5rnMdesUW4gCM0LtG5sxUI4EJqSjT4NBVu6DuDMT/KZ/5WGLgZYWBj9dvTWuKJYJjKNzJ1URvhF+adZcZeDJM9WjqYs
etvtEgw1DhY1xvmL7PZ//LqxS71mer/arDvjYNr9bbGLkRBOfQXc+P1P9jOjvxuioOxM/KuNuE6bpQPd8Fg6n5zRwsOz5//m2PSQVyy3IJsr5icPPRL4iN7X
uBVltLnw4r+iWIcmNpU4yesBxBNgQxxAeo5dUMOeCnuR7xB56hErL0zpe/yD9+AcqsfOjSTO0mKsiD5H4WKFVy02nmfKKe28pPQPGnZYkPV8qiQZMBp7uIe1
eYdpw/ig/UFPeQTPY8w2rd8k9iyfGiDftrseJ0PzTIJDzyTGpxmSF2sKD91Z8qNE3KH2yRzc0mtaywuGifvwcVPIwucenHdiKdmYi+6MbMSIiGoSTLp1x0I6
K7wR/onqls/q8GxNjXqFi6eJINaCziwsniuR2AXntkP3A/pXPi2MTMuPLH2rOzdF6ZnaNnYrFrvvsVLu40MYHIBGoQ40DzF4gJ/oz2RuLj0QfDQ+tsWWQ2V8
vruOw/HyHT0WcFOTOZzVeCmYn01JQUat4/ESDhZyYjpPnLc3kxbwTqanm1VJyPFyVWIcilI5gzPvhINxrvGW63gVGsqnCuPqA5rY0T3s49rgcPUi+SUJWiub
/nR2fKpGBzg6JbaE2/N8R2O+Sl2bTdbIBOdrRSaDaeTw2PTqtjLnQhRcOrCXt4xNYTqxs/8BUEsDBAoAAAAAAKuIuFwAAAAAAAAAAAAAAAAgABwAYXVkaXRf
c3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9VVAkAA9EvE2rRLxNqdXgLAAEEAAAAAAQAAAAAUEsDBBQAAAAIAKuIuFwFuHqZmgAAAL4AAAA4ABwAYXVkaXRf
c3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9fX2luaXRfXy5jcHl0aG9uLTMxMy5weWNVVAkAA9EvE2rRLxNqdXgLAAEEAAAAAAQAAAAA+8zLy8UABOf1hbNU
gfRjBiTACKU/cwCJqQzBDEUM6YyaTFUiBnrGega6JYnF2brFyRmpKaU5qUV+moy3uOPjy1KLijPz8+LjVzJ8Bum9ZaKfm1ein5JYkqifU5xoEp+YngrkJ5am
ZJbEF5fkFwH5ULH4+Mw8oGC8XkHlLQ6b3HyQsXZFbFCHFIPc+YGZkZHxBp9KI3cRC5ALAFBLAwQUAAAACACriLhc2lwJpvgIAAA0FQAANgAcAGF1ZGl0X3N0
b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vY2xpZW50LmNweXRob24tMzEzLnB5Y1VUCQAD0S8TatEvE2p1eAsAAQQAAAAABAAAAADtWE1sG8cVnv3l8kciJVIk
basxKcmR6T+6/ktdOzEcyY0lwrTLDZ0mirNYkSt5bXJJzy4TW3DQngoZDeA4RWEHDRC3l1o9JUWB5Oiil95KVg5IbF30UASob0ItwKhPnbfLXVMm5R/ARS8l
pMc382bezLz3vfdmeL+vz4fI58/p8LkVP0L/QB0f1v6i7r9J6C+QiEQqg6YpTCWApzP0NIMZi2cy7DSHuWke81abzXiwR+REPiNMe7E3geJoBImejUgU9tO2
VuybZ1Pee8CmKNMva1rFkA21oumkyZ6SjbMp2mSOapdMYcpQsDxbUrL3qPboo/OKZkxUtDl1vtC5Y9rZcdHa8bsIUyLCtEhhRgyI9EZ0jsWs2CcyFseJ/SJr
cbwYFDmL84ghkbc4QRyAHRPOS6SCxfnEQdFrcX7RhwPz/lTY9B7LnZooqWRD98CUBbpjP4z9T93/tbWfH4VKrmjGHZdjUddnBO1vc3lUYpDL5/jusTmhR5+/
uy9P9Rq5n7K/E2geLVHZFG/yBcuwpoCVCzVFN3TTIyq6TlxjenSHOavIRQXrJl+rFmVDIb5idaU0h/uJKh2OlkiYe9JlzUgTsZwu6fI+SQavpeVaUTUk3ahg
0m73FSz77aoSZ0uSqhG5ZA66dt3l9MGh9L2E/AS1osMf+e/GNjdj43+JjS/HUovsR4GWELgauBJoCsMNYbgpjDaE0Zs/+E32V9lvhL330ePOoR3nHLCcQ4zb
bR2Uo7v7+okp+9FeegzhPtJM0dapzYCCq9KsrCtSDZdIZwQ62SpBctsgeMjqAbHZ33E60g7ByYbtkw1Gr2+/tr05uK0xuK0+sP3W5K0D9eCeurAHxx8/Au8c
4Z9POgLT3UdGct29IuWgIU89O9ZE+iCj0Xm6F+YcFCcQkQe65Vvb3zIxABkRXF/DGMkyIrubGkMpLrswki5WCrUyMZ+ebkNppw0lAkxszCqysUQgeU4HrBpq
WanUDJPXSYap6SZdOb8k4AFkuaeiGxgMb/sw1sa81J4j6QqJhqJuhrCsEs/OVbDU1uIhAoOsiBNkYoqx3e2pypdKFbkIwaNXSTJTdLB+wnG+192eGX6EALcz
TMbokLsIDMKx64evHW6GxxrhsVYgdHXqylQ9+v3lwKHWQLwViV/PXMs0I680Iq+seFBkywpiI75FYcWHvANNIdkQkneE0dbItubIHvK3ePHqh1c+/NmP68Lx
r+O399W/d5xwNpyoDlsDJCw49VMvGE7PAyZao0iqejKYqFzf+nKNnnGBlKdnQq68HTou6GiQjwHwepxJZNLuDAI6NruwZV3QnavM6umqohVVbX6JwkkE0ILO
Ja+NM2Ze6YAZfhnIOJAESH2qrmoEVlpBMdmSqhtLNpzwNksRZNC1MIq013LTKKxlvvQIUb3ksLx+Aa0B12gjPOqC69XlwGtrkcWhyNgKYixkCWuQFYzZiFpk
7/qHPi5+Ntn0Jxv+JFG8mGkFw1cvXrl4g79JLwdH68Jod+LyoDbSfoteDNIgI4v0XsZBnMg8B+bYZ0tgVup5+NKTUbDgJfIPNEgCb5KqSHIOVuSyBYkUb4EB
fxdIDzA4SYQniiS1aHn/Mb87qjsd2+n3XnJQrcMVbh2/xw988f4XZ79W69Hjy4EpMqQru4z1zi7B2GLQdm2no1zXrqD/jmvz9HO4liGuZfLMU1zLPLU2MU+p
TazIWbWJz+KDpGeBTcuF83gzYfEIkFEgY4SkWDu0DwFJoXaQ63AHTLh+jpHJWuWDklIkLux0dfKRq9cZshu0ldG6JaTt7WI9OrEcmPx/Lfkf1ZI962YRJ4R3
Otdvp7DgXWQ2ht1gcDLehx5PDxuctO+mAfcKP9pdG7oGAXCfWCCmlwOZF1kgfDdfXw6O14VxGz6d7yB4StlZxIYPnesEl2NWFxQ00lgRUniP63qeFZl9zG5K
40gU94CW4+owOs2f9ujMbvKACaPjUYRqRFt1Pu+B0nWC7CjPz3hcrR5nbYk+QbbvJ1DSBI3XPPlnf5GhXtATWSvjcR0Zj8sLHTBEuVD3rNxgD028AzrRc5DV
vCK8zV0gJtBb3txQ96yO0PDmot3yNXnRm9uwvgaSFwXRC3lRpxMEq5pH4+dQEu1wdRTJCeeIbDNZLYGyU68ym9EclfJlTRrPmuycWlIW4nK1WlIL1o8D6UrB
UIyddlFd2P4cQbTgSdeq0KXuoKDUmhwo1zHs1arOj/J0qs9kKyRKTFaTy4rJqYZS1k2GLGrX7y2oXb9NpixffPxGB0ez3wQ+u5z7nDeFWjS9sKgEb0KTUy4a
WDb5s7JWLCn4DUvfeeWSyb0vl2qKvTGrNkAsJNyPHerD9mG6gliCBcyXH0X7k8ZNgvYWsgK+L3r1vSvv3Zi805e8ebo1tOmG+rn2qXZrrn7gVP07P6xHcovH
WgcOfXX+y/O/L98Jv/278pLvFnO79vOTH098Fr6R/+XGT05+WV6cbITfbsU2Xb987XIztrUR29oKRa57r3nrw2/8yXf7cl18p75xZjn0biu6qTW04W58043J
zzOfZprDRxrDR26Hl+PHVrwonlpBXLx/8fgDD+ofWRl89jL07/sDKPIO9YAj877pSz7UwZ2fbJkYZ77yTiSFP7AhoJGjIdLzxyQPdNw3sd/TfsJb7s06DQhi
Uq03AZ8Bm/NYMWpYsxpEAMX7YX9RLRgzBBo7Eke1S2dwHkafRtavAsAvROA+P7N21BkihPr/0KtqRuJyggjI3dC6EbyFnGsBKOlQJ+ATrjwLBLIlPtljZB/8
iALIlSTTJ0nlSrFWAj4gSRdqcqktCUoEAlg3SqqmaBVJwlEH1Xg7EPhRBB8GcgTI60BEsMKQZL2A1YIkGwZWZ2uGokvSErJyuAVO7HMI/Os7CfkpWqUZbt+q
j+L2rvIebopaHfRyY6tRDxdfHXTaWx9E/YS1NcHclB9OMFcjZid7xlDs4IltnC2ps5YBTN64VIXaCHkdw73ALpCuA+29nAHi7s8UDttGeQ3LpAlJVofgX2Eo
ivobGvkWxf+OEn9FwW/R5gf8MYqaov6FrC9Lx38AUEsDBBQAAAAIAKuIuFz3aglpvAsAAGsWAAA2ABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVf
Xy9jb25maWcuY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAEAAAAAMUYW1Ab13VfeqMHkpB4GLwCGyxiwA9sx9QhESBjDIhUKzlOcbKz
SAuWI3bJ3ZVtSNsh04/ipjMmbjumX2WazJR8lU+3/bF/2nyuZt2RZkf2eCaezviPDJlJJ/3pvStpkYNcO19dm6tzzp577r3nfXfX6bRj8FEGfNf+5cSwr7Ca
x1T+wXfX4fg7jMEYfBK7iAOcRjAxSQAC/pKT5EUKUDqNmjRdNAOzDpsmLRetwKrD5knbRTuw09gVqhNjLIz1LN6MQcjWijH2U0R5nVOVZYGDcTGOVuwaBRoY
N9OgQ07GwzQyTgRfw4FrgQh7nyHmMK45OEEQZU7OiIIUg7gtzclcKstJEkJyIMsLKTHNQ4R6m5OvohlZkUuzaVHmhesJiJvngbgCuWoPT1YP/1Q//BUM4AwG
D4wD8jA8/BWKIXvgGx2iDMikQ2YIWXTICkyMDWL2CmbWMYeONQAL44SYq4JZdcytYx5g07HGCmbXMa+O+aCCEObXsSaopADEgjqGQ0Whd80Iu+JiDjAtSGWG
at2Q1s60PkfzMG2gccEa7tAckQVekEdFYT6z8Az5hUZKMtAaeLDEznESz0JlajYBKpNFGtUcOijxKcDLwIz4GyRZBNwCzwJRlGPf2eF0+qd0TBR4zQa1zQky
m0lrtjnACamrEHzWoa+SEWStaUnMZlkI8eA6l0VSRSEtaaG0eEPQ7QX4D3O8JO9jeXYOyWi9ynNAnuO5/QxasDpVzizyYk42ZiIja45F7iaLTpDhpYRGzYli
VnPJOUHgsyzc8lyWT2vNFRygiQKfMkSk8Bqfocp+g++e1n0micWx/Q+kEvupDM4Q/eQhLEzGVtoiuXRGPp8Bi0xZm7pZBo71D/YfC5Mrjst9Maj3vlE4rDgr
CKMbYcWelHjQp/N/QQCXbvJylFASn52X0G5p7cTAIpSHwmQgK3GDLKfL59CqbNWAZVpK94X+pWXNAvWb5oGk+WqcpL9CbIVipRAcVrEdM3aA/sPw74eL7aFC
e1++va/YeuZrC+Wx72CUzb6LDvuc0kxVpfXjL1ZanKynslMVOQKexOOmOhxEleNIhcJ5IZVM4rOW/TwMtF8fkkbM9L5AHlVXnuk5eVQdeUQSF4hZW5UHntG+
XzqkNtRZ09xv7FAgk4QL5s8k6cJOImexxjR8YMV6VZaXpKGBgRXzDUn/tSAKAkw3EA6QaVbogbSYyi1C40kDFSv3la1cdu43wxYASwCmmQEM28ySBqMXhpR0
IyNf1UiYRQFSSY1TUaATsVMoNWiWG5KeIzQTDDWwLKFYoOEDuhCPvRI/KIEEah1oj45kSZ9iyIeeuH0FN5130wV3T97ds0Wq7qNrVNHhvv3mrTc3hgqO7ryj
uxjo2PR/ntrq/UxUuk8roTP5wJlb40Vn4232FrvxbsHZk3f2FAPtm9TnzFbLZ+9vsgp9Kh84dWv8SaB1bbzkadmi/uz6wnVfLoy/k4f/Wy+rnnfXTEVvy3qL
0jp079S9HsU9olhHQDfc1RdYmICa4eUcEFZc6UxKnoVaOkrD4b0wAU4grSDzhb2alWUFbpFnWc3OsotiOpdFcAPLfpjjspU3bpadz0A9ZzMCL4hlQk0dY1ng
RRJ9aAigIYiGFjSgYAMH0ICSJziI9GtdAuISD+RlcAjRBhCtiYXBDMWlWE6G9pzLyTySexwxoBPplgH26oD+pE04/BJ74m1Sqaai2/uLWLHRr1L+ojegUoHi
AXp1Ym1FpegaKNSlUC0bJ1Wqq9g3oFCHNyWVGih2H1Gojk1SpY4Uuw4rVNsGo1KHi8Hm1cl1n0o1F9sOrsbWx1TqYPFQt0Id2EipVPcTk3OXMJnsuyRmdu0g
aMcFoV3CZhrFdWIZLG8ebTdVm0hJrJJH/o6hPDJrvKiXbjthtBlRRSSJuXYMmzUiPmlEXNyyf261cDL40GWYAaD0JH7oB8+O2178jiF+BEe9GGjYMBKs4cc1
fFkjREEjl3lJo2SQ48MmSJA08wKPGhnd+zRTOXRNWfEGD8KkRiF/0yxpfp7LZWWNBNwNCWnKiEwbC6ezqOiBCxBHniNNYigKS/aG2+FfhdfHPhlYHS1a7B9/
9NvURtdvrt4VPhXUQPdWpxo4WnQH1lwlh2tN+uT128O3hlVHW8ERyjtCqqNL6Tmfd5xXqPO6wZ6zFdKLbquH2P/DVh1YTS425h/CQtiskZctGNeMMu6ss0ph
iCTmQtWDOAez8EkjzydJHvbBAhkh5/F57A3yIDaPh6kYQDZc8U8IsBPJpGnUlCzwgJ4XAb1CDNEw2UYgAxjBKokDjKKhCdnEfonL5vgoACJsvuI5AbUtOgZT
bhRxnUfDOGIl+Zup7+Vaq25RuB6YhihqRaSFH2bQHYI0TeElt3f95Cc319z/LjmadjEckVytSvvx7UPbgW1wr+svQaUtorpGviQV6+R/dkjE8Z2EEtVf7RET
9sBkj/SQD9y+SCf5oNME4efqvrfqAzepct03airUxqzBuWd7Gpux79GrlqWxWcNn4tR+D+jUW6vqHaOejzDkXh/xUllUjaz/EcGw2r9MkukVJZEvlWR+RUnU
SyVZXsph3VvrVdZ8A69aNU0gW8E4Mb10DdurnaeObPMAJliSe/RG6D+WuGO/jL3ekYYZ4BX4qRp+8hX4TTX81Cvwm2v4LUYkoEy3l3/sjCPu3j83aanORd1m
Febx2UaDw9h93Ld/vlGRml78TrAmrfFgnX03wH/OswSMwhZjNTxJJMmkNWlKmmfbDE4X465atYbqqUttZLx1qL66VD/TZFDbDWqAaajDG2Saq1Sm5awTVtjW
mNYQjb/NjkSYKJuMT+lZGyADaLbYzFiUHYWD5tBBJjoajya0BiYxE4+MR9n4zExCsyWisUgswU6Mac5IcmwiwZ6fiE8j1DYSj8RGL0BwhZ7OSFJGWKDRRTQD
+DRdvljlgN7uDcGKcJROwAbTssQB1KBrVv5mBl5YxQ+0prdnpqbYiVgiGr8UmUKbmImNMcCPthgam3knNjUTGWPj0R8no0xiH5/WeiEaiSdGopH970AzkhGs
Tk1MTEdnkgljpmM6chnKTcQnogxAOtRciWQsFp1i4YlHpqJjWnMFj6MZseioMRcgFw879evE3oUBeNDwQ/rasBughF1TKcNYpVJqZm5piRfSADUrGnVNzAjl
TxB2/uYSJ6Rz8CKsWQAvidnr8FKy+EE6A8rNLqqKeq8TtkE1C9dhI57lv7dVzVP7IYOFXdPevjXLYtma+nkklKHo6lOuwOXvS2UTA1R730KMf8NREX5scZes
vvUL/7S2P7X6fu186mu6e/bO2Y33t+Kq71jBdybvO3OPVH3nVqdKHu9d2x3bxmubQPX0FjzH8p5jqufE6kTJ678bvhPemN46oXr7C97BvHdQ9Z5enSwFW+4u
31ne7Nj2qsHBQnAoHxxSg+cUym/I+hz8afmPy9sH73eqPVHVc77gmcp7plRPrOC5pMQvQeH1Vy14hrbTqxNPXJ7V8ceOYNHqvu265Vr/+UNr92OHv4p+9NDa
9dgRqKI/e2g9/NjR9hyzxYW6iLY37lP/sD2wfdmjvhXPt8VVF6NYmae+oNJ8RPWFC77BvG9Q9Z0u+IbzvmHV99bqVJHyFKhAngpsyFtpBUEnn8KlmkJFb1vR
f7ACeNtKHd1Kz/B9Qu0YLfWeUE5eUi7/RO2dLXW9phy98GWn2jVdoo8o4ZH7Iyo9XvIf2HhvK6L6+0tBWgkd3x5Ug6+XQmGld+z+nBqa+LrN1WDewVwms97F
hs3lBkxv2PQuDKWc8u2vve77JuM9gsJ4DPZwaYQ2Gi/suqejy+J8Dl4v4fUQkFWv1xzG10xeAqheag3wspzNzPXDVAF9XL8dwLQhX4W0cgSYy182y6Fjr3p7
2e+RN37vCqhZz5UvqcNgEaKoUkgX4QAbOhx/hHV+hdkeYSEIPMKaH2EdX5sxwrF+KI8HvyVC+Ci+g6ER3s+I5h2d8M0FnMLH8W9cFnwK/9bfi5dV919QSwME
FAAAAAgAq4i4XOVBTYQKBgAAZg8AADIAHABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHljYWNoZV9fL2RiLmNweXRob24tMzEzLnB5Y1VUCQAD0S8TatEvE2p1
eAsAAQQAAAAABAAAAADtV01sE0cUnv31ZhPnhzQQJVGzDVRkwU1UCJVQWiobHJrYGOS1UWgitht7nWxY74bZtQIoEj1VpKJNEaoEJ7hyQ4JDD1yKqFClShAF
KdEK1AMnbohEQuqpM7v+S9YWP6p6adf2zOx7b968+fy9N7vrwSAP0PVosH020gDAc1B10V5HrH+F2p+BBCQiBsYISAh4TMZISKKeilFjNKRdGR1jxhjIjLGQ
FUAn6AMS0wUk9gDpeYKBaVIMvMBDkXAaFcMwbcXWTMNKoHv6hGLPiKTDZRVbtbW86nC4vWAaaqY6LLIUVsQNaxJAQgIoFAJSUoNEdoFZFI5EdQHISLxEu/es
1IhDQaOA1CSx7oiTArBhmhODTiBuZhT9SOQFhzxmyKq1GPSj8FpZd600kQT+K0n5ZRKBPuRBUgDjrXrZYxokA37bA8VeANNApBIphEFgToGqYVsOp57TLFs2
z4i0w3pCh8mfyWrQCWSn5DkEmcPImqHZaBZtqXoOtiBXFl5SEJyBwbxhDyJAlUHdUoZkZRo5GFQKWc2WLduE6L4oy04NzJ13ONn1JctOaxGTgZKkAbvdjZpv
wRrXstS82LzK9SxzPTdTt488xqODazs+vNy0xjUt8Yv8D03reEsZomqfVAnMORfMiQqA1ZAXrz4MFl0DrKJHg6iaz/rt0oROV8a7EOESIuMErLO6Zqv7nUDG
NBCvbBcth0qa804jNOflnJJBsJy/RcI2rKCxWRFMuA11sBOLW0rgFL00Y2w+drF52rJ96eL3F6/ZNxauL6y0hC7RT7v7bgxfH75EP+E611raLzXCjq3IsCVk
7rnIoJ1Xa0s7L/Yk2jsiYk3SldARQB0LcpMFU38VN5/Rp5S7mJwfgRDoL+qzyE8O0bsXabDuC6oX5AhEXyWGNLyw5TqcjIZTUSEVjsSjwuiIkDieEqLjo1JK
EuagmVEtS82WGTlrTllCv88HvpBK1rJCKjqeEk4kR4+Fk6eEWPRUqKaxjv8lN0e8CXjRRDoer21tzSj7Dnz2NpY5TVdlS7ugCqOJVPRoNPkG+8oOFXuzf5+5
6JMoqffGM2vOG7qpZGWoni2oll0P1KL6PwasSHnJHFDPqZmCrTpsxsznNRt2g3IB9XK+FZsFywUR18N2bDPiZvxasG1pcnHy8ulr9lpT61J8Mf6S5No+2KD4
YPPL7QBpa4hii7HLx14PguaepdM/nv7LakSuruwPB6lfg3y4O5CpzszGUnXoJ96qOjBppnZ1mCh7TZcrxfjOifKRlKZL0gmuJEvyfk99YKKprG+uEUvRS7Kt
fpxDTKWyoHjb61u+ZyUiExfu+f7z0YQUTaaE40khGT0RDx+OYqIdr1OCfLP7veoTquJ/qMjuUIW7oU209PPuZDiejkpC/5chofL1m93iXXrCXoAPKMuGDqUZ
NmTcW8Och6w7KtgZp0GzzJwJ84oNBSQUWe/wYr1wHb4SrsN64ToN5XBhD7K1sDNvXZfyzjaoZky4CQ+nq5QBft0n2IcM6qUDjbjPg20dP5282n1t9u7O29qd
gQf9D0P3Q3+ce7Tw+8JKZHI1kluO5FYiM49btY1gAOVJayVP2kt5so55dyUsRijqPsVH2v5PFPBPJMpv754ovrPFny2VY+XfyRi4E1TIz1dWh3uQAO7FTQg3
NQm/o0jqrftyerewfqvBQextCryR+l9f7b25527mF/GO8WDmoX5ffyJNrErysiSvjHyzOjK7PDK7MqI/bs3XZz8cxBsk3GfWhEi6PcRPuejZn4WqXYCGQyfQ
+5LIQxHrduFmT3m0tzxyccAPrHAIN5hBaM6n7z4HvzEYSl5Fbwy8LOfNbEHH4yZZPltQ9KKmRZZzGrRsXTNUw5RliEnsVTd8tEJcPeA+/C90yKiqoFfCDOKB
DbUpdCxbsnwLuI/M3mHMlRr8s/BZ/R3YICmm4xVPMQjrTqZrY4hnul53osabhy1FDgeYKyCQUEgQpz3cgZcMYFLq2pQLpFte3crqrTOMm/LaDve5t8ND8BC6
JUoBvKQIgngG+p6D5meg80+w+zU7THS9AqhxJ/8NUEsDBBQAAAAIAKuIuFwtXS5klAMAAA4GAAA2ABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVf
Xy9sb2dnZXIuY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAEAAAAAJ1Uz28bRRSe3Z1dr72xceuGJlQNzi/IghojlEhUokUClLZJcCov
lkA9LBPv2HGy3o1mx+BaIG0lpIpbgaLmyBFu/BncGieRbI0UqVJz6Q0pB66dWf+IkX1iLO++N+/73nx6896eJ5MJwNfzXGb3EwWAl2BoKd2XdH6bP38DFrCk
DbAuESkrbHlDJjJ/KxvKOiQw2oMb6rpGNG5rVsxS3wK7cFcisYps6q9EKlNiBvI8nyJa9b0gz/1MIfK8ylrVxXeR57iY8G14H9GdkjSkJtZVJJ1rslBTBLek
pV7ImQQgCx4MoAUZjKw5sNqzPKkoWdINg1tyUS6oo1hL5j/lpsxzaoOc+ricFlztafSUolIwRjFDGSZGo312FnB2ajReuDSGA/qcodyXx6pTB+rgEPbKOOyg
OmpRLVwdRRThf7ROj0GoF4gH1wa7sqWJWvN+0K24lbgJPa2o/c8TtGHEAjCNPIOuXwm+NGUW20cEezRgOm5UA2r7eyyD6k6V2gH1Capgm/892lxZXEJBiVZr
2Ayy32cXl1z8LXY9NPCHzBoOAs4yg2Y8Ii/zw6rhjzPgleiaplqn5RsfmQrTa6jx6UOKA2Zso9Jeff8zv+5RrsQr+Q7vbTNFBIEppe8cptb2nCphMZ6rwmMs
XsF0k9uYMD3gppDD4L382hbTd7oDETC15GJEWHzNJzVEKccmLUowqvVGhk1w6kUwgRynFyGQn2zGmL6NAmyLkyHxfS5OFC7yNbd7erw84MdKfDx9F7OJMh9L
uy9DzGC2u9iHuZpHcw6iKOcGaKVb3dyYiue66Zf3H0Yi6/t2d4PM82xiqoLn/BGCjpFpG7NP6bPGL41fm+HnnTcyT8o//RDe7cCJx/lH+YOVPxbCfAvmzpJX
2smZo+TM4dsrx8nVcK0DE4+3Hm09vfNs8+fNEzjb89twugWnT+C1s/TVdnr2KD17OPfVcfrr8N5ZarKdmj9KzR+nFsM7HWi04VQLTh1w8Dwnt+FkC04+qZzA
mbPMO783/nq/9e6tv+OH9jeHe6SVCcLNDky34fUWvH5QPYFLFxSXJ3hhXAq/OBddakp5ExIxCk1DfM94T+V9DzON8DIQr5nqtcBy9/pNnSVsu1znMWzbRHx+
iZh/lu7j+vcQ3anoeLrjVrej3oqq+ScgC8KeE2H945rv1F18m7zHXTE4wZv88Y8iSdIpmHsJUqfgg1Mw9a+WkS5HvNdQSwMEFAAAAAgAq4i4XG4hMUfhBQAA
vwkAADQAHABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHljYWNoZV9fL21haW4uY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAEAAAAAJVV
zW8TRxSf/fB67fXXJjZxgiGLFaKYkIRIBDU00AYCam0aIW+CUoy62njXxtRZW7NrSixVinoK7SFqadWUVqLH9B/ooaeq5Q+w60gJi3NCPXCLFA6ovXRm1jZp
E5C6smfevHkz782b3/vNnt/vBeirjXXf+dQFwDOw72Odjtr7GbXfABnIVAokKUhJWKZTNKRRz6SYJAvZls4FXTIru1Jc0g3dSMfKXIpPeqCHyO6UNylAgch8
ypf0Qz+RPalAMgiDRPamQkkRiq39umCXHJCFPnCHhd1yUPYRKXwrIvvPLwCg+gG4dSQOboXjYKIV9gTl9DqVpxOh51hMULagGkbJUq1CyTBn0Zi9rlq3n1PO
pOdK+vrlYkE3LGxZLKmaki0ZuUIeDd3XSlm1OHMJiT5TtyplpVjK53WIx7JVgmpen847K7v2j+cqhqEXs/sTyqE/gxMKSUIznYk0DQ58cZRumZ6kDDAP0q6D
8zKDkgVk1yQjvc6Ck90yL3sciwGQ8M5Wz01XtIIlXS3AJYkcTGrFLJGgpRFpTjU/luTsbV2rFHUopfEx8GEFTTezsFDGGay6RkZ0425VxEmUrJI0ikZSrlDU
E7TN3taLZdut6Tm1UrSq3MhIycjqttdEjnTFghW9ehTtKpUMXTKXjayUXc4WdUk1NEm/V7DQDpyaxV5gAN8OY/MqzJdVaOp2YBrmK0sozut4CG2fqmmK2tKh
GDliBk18rZJ9ZmzJsMY01VLHiqZ6VlHxCcdUnADFdE7d0i2pBWO0vGz7FiuFoqY4u8AetEsI/c0Z1KyApj+y5R/4wz9QGy/U/XdWrm6z3i22p872rLtrg5O/
nq3hwXuvtPxGrDb1IdHe3BbElQ/28K0cjojPaIIIqj3RBjO5febg3XbmqUzn5jNcB0/8YXj6b4EY9HGQEdrz81Tad4ifti2TCf5fPzI1gnaXadx29mEzYscj
09G6Mt1t7cLEwqWJFvUYHIoqfNDHUKtX0V7zXLrnkLhbvQTyzDyTjr5hj1F0or7OiWKHnSjT34mZa8ecPvF6r20b4p17kyW2OAEy8bbeDdQw1s7T6ZMHV8ns
q5370crM4L51KHLDjdYlDlnnmndP0K9Wyshy2p3nnD5H5cAFph/kqIR71mZxwVR5Uhij2iLEy6oRhx1Q1ZTLuiYtLksVVCHVkKPOqajytfPSSRNiMCYipHRs
L6kjXJ8m9CAFxLdqM9lPNOgmEiINiBEEMaAgfohslnAFDyuGQqSARWhU0Q11Efmw3SqmjELJZpAJxOC1BWycQ9xyFzGCmNKXF0sq1N43LFTClbJlswUjV0IU
fy+rE/KyPXpbTPAwQrziGG3OIXabc7jfprVFJJNnwXaRfKCQ72VNHLLU+uARsh4TCBxDIj64+RI4fNH7xUeIJQRxS4jVhVhDOL4yg5Q/DDwaejj0/amG/+TK
1V1adIlNMbY+92jhu4WGOLjKNf3hr+e+Xfhq4cHNHwfrkaGN8/XIRMN/bpVuin3r5xpiHNmEYus3NtKN0Furrm0huDa5NrntE9dm789u+mK7XhASdz0gEN7y
x9Gv6eteS32e2oye2phoRMe3ohfq0QuN6Dubvnd3OWzKAH/Xlu9Y3Xds09e/HQy9bAqxPUC7xG0+sCbcF2rh4U3+9JNgqCmE2/otPlrno7Xe4Q15kx9vBkN/
77rQzF/mKXT4X6bHL0+B305fRO3vwyHcTh2ducg87vXgNt41M8k8nnQh2eYVBSdPUTDvQ/S+QqPa0yb80X/zfYKG4wQ4BcT2PbZXUXIVtEBXFIgpEnbhSXcZ
vUnFwmILbeayCU93MIaDI+CDw7gh4MP1QvBpsxhbDg45B3cEYRDXL4E0uWIcsqEuIa+2V142LX3pCnq2fgIQk4yDB35qqYSfz4vwbTTEJWteQ80uQ1HUDog/
A8EdEH0KvH+CYzugfwf07ABpBxzfAYNPQeAFx1JdLwIRStw9A1jfarXB9D7xhr+ce3Cj4Y3V2Bi6L7aPePsHUEsDBBQAAAAIAKuIuFxYn0FqmggAAJ4PAAA3
ABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9zdG9yYWdlLmNweXRob24tMzEzLnB5Y1VUCQAD0S8TatEvE2p1eAsAAQQAAAAABAAAAACNV91P
G9kVv/PhsfGMwRhIMAlhYEl2XcBOA0TNx1IIkCZxYFtPLKXr3VgDHsDBHrt3xiFBlUqeEqqsoB/Rou6qStVKpX2K1D7ksdt/oLYGxe6UVJW2qtS3REk31T71
3Bl/sXbTTsLcj3PuuWfu+d3fOX7p8bgRPH8Kddz8uxOhz1Hdw9gN9fJn8H6IJCRRYXSFwpRI+nSYxrTVMpixWhaz0DJhxxUOc5YOG3ZecWGXiD5gIz7U8Awg
yTFO2X3slnhJkLgedJO9SWFe8khO0seC1Cq5rJ5HapNarF6r5JXcVq9tmQ60/5MYCFAmL6tqRpf1ZEbV5mHMflvWV6B1XUiqMr5z+b319tiNqZH35ZH1kyNn
gnFx5MOhRbrOIdb+auplyfriKHqXeqcsSoAefBMVoRq/Y7zcRphGmURLzDj9Bjlbk6soxlbmI84mukwU1XSjqJkvtRNVm/gfpQZRgJs3kUmFTCpuUh+sM2Iw
HmBNh6bjZNZ0YiWbkhcV0xOXpi7OxqXZb83Nzl8zGS23EKBNxy05lVNM15KcSi3Ii6sa8UYUzdFQWtVDCVmXQylNHovLywqM5Vwiqcc1PYNhXJ4rj4LZO6ZX
k9WknlxX4pqynAYhJhgR4E/7Lrw2UMnj24ptLxSFvoLQZwj9RSFQEAK7b+1qhjBaFM4VhHNPZv7wliFc3JjdF/xFobcg9O4sPho3hMDGbElo3wpvhncchnAM
Rry3yB/dvpRnj74k53Eg7hwqx91N2XGvnJq8glAIqXSsetKxagwhFuXZiKNJHCiJrsSqqsc16nWgS8chVkyUkZhzZEdYM8eDdTrS0qgd46u7MxWrlVZEc28j
xJMo01X/XbCm6nuUPo0G68Z11iiJ/aq3gBTH+tWIouewKsqiJi8pIlZScL1uKWIWbtawmMXKLYhbUl22JkQdy7cUrMkpMZPTtWRCEcvhFnEmowcx4RoMLqEA
gwm+TSq4TgeD60Iis5gjCAguJNUAh8ktAMjpGBM0mI6sjHXN5ORsVlETFkoAsS4sr8XJtmZ7BY7xpWRKUeW0gg+Tday1jhgT4cHtZM5HviNe+Q5rPT5qxQFg
9z1EYPcPZ1vJ67vv2O/s/rRj5/onvUV/sOAP7uqPJcN/xug8W+yc3OucvD9TauvY/oHRduLP3m4CtqubV/f8gd1Bwx96Kpx84URdU9QXLiS073cc3unaPp8X
+vb5tj2vmO8/9Xje8E4b/EyencFk6+Zw7LTgeJ0agUC9iXhUujkZVOVMrGo/wjbqNSObWBV+NajVzdGVuRDdRFpdcXYNQMjXQ02iKjKFApALTbxpa5wDGgUi
PUMTcAMw2fn1oYiylNMI9PSMuIaTuhLCipxoirxrQF1OAAMgTDNdyu2kpsczqwEXoTstk7qlmHRGI3ABMLkXM+l0RrWAcQh2NoVIDiCeVmYxzmCTs82YjvRq
IokBhkKF38hGpucAskxOl/GyopssEX4VieW94zk1oWBrOR4iGqiKxFJrT6H1a0XPaMEzanjGNy6W+MNFvq/A9xl8/8bMvrPl3trdtaLTX3D6Px3d0T8586vF
3RO/XM33nDKco7/Xnoz97o7hnNxv7ckfuWS0Xs67LpdY9725u3NF9nCBPfyI3dXzpDdW4ts35mwk1uOoikQfzN5gHqJYLUr1iC0/AzXEUdH/QouV6NMozcSq
uIsyY0CYkNJdtXQI1NhpYZtqRoU1BALxHbKJT7L+VdaTdBfhG1dWfBxE/Wi4SvUJsLYE+OqDdf975btMH1qiAIcmjRcW63UYy3M4shPQmaAeomvN8zRVx7P0
PJQwXlLCsATD0HIrsppIKRrR2bDx4jqfktMLCXnC7NVW5FPjpy22C55PZRbllDYRrIiHQVlrhde/N9CjE78e+sXQ7urewFkruNYrwJnOFVlbSSUXTM42ZbIZ
oFaThVsEEM9lIZMrZsuKcjuRXFY0PcDiAeICZw9Nx+JKTl3FXyc7lUE9aTvJ17mGz5PNiM5N25t9T+fWjR/eMDy9gOQWz5Z/07899qBv5+J+l3+v5+yT6add
k/chb/u25jbndo4/FcQXDDo0Rb1mkNv/nEOCr8gfKfBHDL43z/a+dsLsg76G+S81ErndwFSA+SzgvsA5m3Przy1u/b/xTEsUJGmqGaZhtklil+gRVEumKhtl
m6FYYmr3QXUcI0VdE9xBcq7Y4aJcPWLfOxV1RDxNfOJqtwNYtgmjHtBgYu2N8yMekjnmwqjxXrGRjiYWqZrF66ciXY0aldMco9909+ZPlm+XYx6TLdcdQZLI
TXptAYdgHODxGDSYWDP5taS+EtdyS0vJ24Bmq8WnifgkETuszIC/QfoM1AZWQYHPECtOUNfhuqVNPgGghp8I5IcDPmst0+F3RMpk9HTWZCCjYFKaaYStRPux
0d5pWY/bZuJ6xob9JRBZV+O3iJD4AdQLHihF3V1Fd3/B3f9oqDgwVhgYe7xWGJgw3N/cmC61CFvdm93bgw+O7YyWWn2lzp6Pwz8KP+KNzuH7s3/t6C55u0pC
29blzcvbyp5wtHpX9gQRSpCfSjtdP3kfiuX7sy88qK2DXJzu517kcN0L3w1v83vsUVIH+wYKvgHDN5jnB/Ps4OsOUPro2JcagdFnzinPhQnmjxPuacFJ+Ai+
Lw0VGQGQlQ6xt9IzOWzVhtYAVPwkVG44C/H74nxGVXBPdc0V8mJttbcrffxOtVeT20RTN1s2Pk365O7hmQPy9TY9l00psaSqD0PKxx8GvKY7Hl/KgWdKPI4J
G9to6Scu01ghhYBOuM+uMjn9ThaKCMu26YTcnyUhJEdh1ZlWbWilZYvNrNj+xqbRCiunM4lcSpnA34Ehwb82Ca/nDEVRz9DA56j1L6gF/j9D3c/Qkb95fR+7
f+zOHwkZ3pMbni84lup91eqmZqlXPRzle+VzUMf+5fVQx60d/gNQSwMEFAAAAAgAq4i4XClngrReCwAAahUAAEAAHABhdWRpdF9zdG9yYWdlX2FnZW50L19f
cHljYWNoZV9fL3N0b3JhZ2VfaWRlbnRpdHkuY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAEAAAAALUYS2wbx3X2w+9SpESJtCRL8lqS
5dCWyKSy49iS3Bq2EkuOhYQrBmnlZLsil9La1JKdXdqREgQKeojcHCwUKKIERawCQeHcfCpy6MHoyc2lJLYJ2YWSGsjJhwI0ZDRFeumbXZKiRSYtipYEZmfe
e/P+8/bN7ra1eRH8/hTrvGr4EfoaNfwc9oPa/RjGXyEBCdQlNEthiidz+hKNaXgyl5hZFrOzDuyw4Owl56wLu6y545J71oM9MHdi7xXHIBJcgvs01Y1g5ulF
gvckbcs4WRWJOaFD4HrRVRb7hKDgs2ZtQqfQZs38Qpfgt2YB4BSIUkJIaCfrqzRuX6Ij4YeESYQyOUlVs7qkK1lVm4vQplPSUkpSNz0pSZeSGUnTgIh9SdKX
4cmcU1fXuqNSPqXooqZnsbQki2o2JUevall1HiicaZxdk9Vko3eYmndesbxzBWFKQOARCjNHwDtXWIE5ChhrxlozB8yc9ZmrjnVbMw92CF5YcdbKh51LbRG/
GRBsfWZSsqor+upDDrAmo+nY9Mk4Jy5Kmizmccb0EIXFJAzfegHLv8XPZVXZ9OiyKqm6qKRMzyKW1OQymfpqVuJsVn9IWSwVVTfDALOIbWHidRlr4MJP0FzE
ZbpFUZVWZFE0vaK4kk3lM2TuE8Wf5aVMFRMQxbSCNT2jqLKatQENoRBFHCLCQiL4GUBJUdJ1rCzmdZngwoDbJU41J2Mrqh4jwYplNOmEaOkVezJCNqy2qukc
za1iktLWQJylHYPhXfQgGDLYUDkQ/PlcOdBjsD3V0QYPDhXY3q3zX7BDuL+2OUk1RJu1I07tpqxoL9RxCTRFPVWdpyCbIdepk1VsnEFNv1qmxx3NOIGu7RxG
EWbORCYVizDYRzzigKAqOdOJrecnlOm4LmXyskY28HiAkHghafGKlFE0GY8AgLhaOwLDOtrh/Bv5W29u0QZ3sMQdLnKHDW6oxB0rcsfujBtcrMDGLMNb23zl
f2Sz2mon2PoJPYd5gERoy1Y8SIZhon2DdZ171onZHEknKYMjgOkhhEO2mb7ApuN9bito+PpKvsGib9DwDa9Pl7n2W1ObFwpsd7ORdM3ILsvIBFqoo8Y4KxDU
HFSAjpkL03PzM/M/Fp+feXF67tzl6QiFg/tUbKsfnBxUFjwKMALX/JZyZe7An3uPF7njBfZ4sx6+mh7f7nP2Qt2hCRRnWziXakHn/B46T50zt7dDgOJ1kvmP
KOkWlG0Nsv3NsuMdLfTZp5fAnHaAt9k53A4rTHZEHJgwxoTjHtRye8Rjn3KS5w1EJB9M15Ksk7piJ1PQAmFZy2auy6SWJ7NqWllqjJt/Ma9kUvUSgp8BIMlB
jRx2cnh6d0KHtp0lfrzIjxuhEzvBvq1XSv1jxf4xIxjdCR75eOlO+u61eyeMkYtGcKYZEOreCpd6Ros9o6WeE8WeE0bPs0bo1CPO6XNWkNPhtNIBdGuXMpns
DVFRFV2xMr2eIuTpRtUUmXTYqRpv9ur3nz86QbesOxT86dM0jxZc9UDWa5HK7CVCoh4ulU0wcQ41/Wqy6qf8AuzaO/OBxmQRmATjh45hnKlxlSngGmjmutBe
ty/YjB1EC6G6hmw9oRyC8zRtdRxgWYIZhsPYwCfcig9I72mG211L3XJHgo33NVMBdKAllG8JHWwhh4uyqiuGVHfCFT/SjK/5thNd/C1CeShduQ8TjvjRFvw9
dW25hEfwTcBMgigmuMbqexlkLETqe7im2txWr+rHv1ub+FgLS/z10rDH3/v/4H/mRtWW90CCOx5rpk14IMcCCW6Shmd7wkue447afh5dvkMaBcgQd007qf+J
LO0QgvFnWvB113gQBfcyWOhUfQvjdaq6JLWtVZb+mzgfq8b5o0Tb98dZ6CIxtjwBuZmATnmCqq7hTfrq3xIeq6+kVN/l0aq9LTkKoRpHyNFmtOVvqeO7sIk2
IVSTk/DV/Qlv5//uXAO/plN8GC301ihcSIL3tupsiJaH1JTxevVKOGVa8KrOc840lUZTzCGUpiJhaRHQ57Es6XIM+igFOk2Zh36Zr70D+LSSgZXK68syr8kZ
OanLKb7abPKkbY56vfPLisbncFYHrGZR2i12nS6dzaRkzMPtYYWXkkmLOZT4VX5RVtQlHst5DbgurvKSN6Wk0zIGPD8df4knzXzMZjbKS2qKX1Kuy0+IIMpC
D72YkfdpNaPzqSzQQtPNa0nJtuDGchYIV6TkMjTmE5YITVcyGVBhRVJUm3NagZaKl/L6chaDC7zpLOZlFcYkUZYIlJI66GFpZ2lFYPbrNI/BEKIAnwODbR2j
3nm4erlyEjFLM93yG4qmi9lra8PVS81+b2v8iqJpRJik82vHo/xM/UXYyruWOOu6EX1I2jjycldUwnLNkdfTY88BwA23tmwKWK6NfafQvAqJkCKujCWzGOdz
+hl+LsK26j3O0rWZDePJcBiGNe4Mb9kHoqbWuFFY5KycmVr7jZUm+1ICINC+gthVHu5K18B5epaX+CezoLanIRuifKLqjJq0/V7JSclr5AnBg6ZH1mPJZUld
svc86TmSlSAmyq/RE/zzmJxWu7fqtR6Y1FLTsXItpWCrl7X6ItNpCdZMXzwPblyRpzHOYtN7A1JGFnX5Dd1kybXZdKTyKzkNsxaTTFZKaaaHGGzTeKbfSMpW
F9/KzaYDmK1oJgOdXMOVYMiSL+VyspoCKVlFNVlyrY8E8NME/wOCZ4lxJks6cJJydhSqyQfuMhn5jSQ0gcty8hpoRLI3AyxA1jV51fTXNojW7Yqs7W3Vtct2
Zsp05XOkZqQwKd8aqWz8kz+7szwgqxocDXH/LRULgCU+1RSatJiVduQLlriBIjdQ4kaK3IjBPbV+ocy63519Z3ZT306tzxbZ4ztc91aXwQ2sX9gJ9G+ljcCR
9YsPuMCtiZsTt6ZuTn0VOLQTHCgHn72r300XJpcK7cu7DN3pryC6zV9xIk/brcDNwK/Z294PvR8/e+cpY2j803PF7rP3D2wEiu6Xy4HODa7CMo4r1E7ngffP
/PLMVuq2+oF6d2lLLQycNTp/uOGueFHP4VL3WLF7rBzqL4WOFkNHmyePPA6/c/35ig/1DazPlA8duf3WB2999HbRfXCDKfccvt33Qd920ug5tkG08gUKwVGD
G/3d63/xdZF76tulwNB20OCG7fspoErceJEbv3ve4J77/fl7/tKP5gvxeWMyUZpcKE4uGJOvlSZTxclUQV42JpXPOaXsC5V8fUVf3zazld++dNdfjJ27d/D+
68UXXy/0i1/4fvoogPjo34PI3bHj763QVNsi9cAfvKXcVLY8hn/wkYNuG9pl2IC3gliPt+JHga71F3b8wc3n3lNhEu7betUIj5TCo8Xw6Bfh6E0v2MUFSRw2
Nbhhb619zkXuyJ+e+Tw6XT7Ib56/+WI53PuLFx650IFYxY3cgXfffOfNzbOGa3B7uTQyURyZMFwT5cFjpcFTxcFTG65Nf9F9qBwMb7i+cvnLbt9e3LZP3X2m
2H3ynouE7GKZC6xf+sfjyxTyhXYRRSLn7y30z94/cX+kcPBlwx8vvLpQcC/8s8IQ5LdaN6TcHw4OzUyhP055Zw8yn7kCs2Hms7AD5lDxSDJjl3XKsKznsWod
vkYEnidDwIZaVcJZh5IZXOGtudemsE/kYjabaSDpIB+Q0nmdHAwRk/sKniJ0XP3jnGzXDUzuKuQFoi9nlEVrs+nUV3NwHm1ljtdE2fdA0mruVSrrjFnflOxv
OtaBdE/aX67O4p8g+1alvQYD+IeivkSDXyPuryjyJer+EoUfdEULqOORE9Hc5nCROvAN7aSG4bpGDe8yiO6ukOXjIEU9/dhNUz2PvRQ1+tjtopyVLnSOmqa+
QVNUe4UMlvh/AVBLAwQUAAAACACriLhcevoyGcwSAADpKgAANAAcAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vc3luYy5jcHl0aG9uLTMxMy5w
eWNVVAkAA9EvE2rRLxNqdXgLAAEEAAAAAAQAAAAA7VpbbBtXeh7ehxdJlEhZlExKFGVdKMWyYlnxxpYsy5Jli7JlmTQdObRDU+SQpk2R9JmhL0oWKPZhK29T
pMFuG6dYIN5ugchosFGABaJgA0TZBqiLPnRmKe8IEzkxsOhDXgolNprA+9JzznCGt5E3LtqHFqWEM/+c888/Z85//c7Mo5oaEwF/7B7b5cUWgvgDUfLTigfV
o2XY/jURIAKqacKnAio3otXTaqDGRw3QwKNmWuvTAq1PB3R4XDut9xmAAdO6adJnBEZI6wOGaZPPDMyYJqctvhpQg2njdK2vDtRh2jRt9dWDel8DaPDZgM1n
B3ZfI2jEY+bpHb4m0OQmHISHCFhaiEDNkFqcKXAk1N7arxDpVQnmSDqdYSJMMpOmZ7xqgYxFGIpJLlACidrFTJqCXNrZCHMJHjVj6ZtfqcQrjUf9s+OpJJVm
kJixBCTGM+l4MgFPDScy0Uhq4ohXJzQAis6krlHhXDpGgTDIZBihgY7EqTCgUvC+cCQLhQtm+lJk79AL4XgyRQlWOpJOMslFKkxTiQUoWbBfB0kGnjKAiiyE
mQzmg3dqotJ0DqCBDIgkqHAyBrmTzM1oqY40ko5ewzo6TwBVgIB6UUGd1AbULcRlqJNAXUCDKR2ktJjSB6yB+oAO0ZdVwAD79bifDDQEDJgyBmwBElMmOGrE
lDlgD5gwZQk0BsyYqglYQG2ixrtDsATEmeIF+8oKJxTVVZiTBk31V3iqc9aUPDTnTqlkeiCllunDKY1EB1RBIqWVzkJmiQqqhgrXwnG93Ev4Zbr489dV9wXU
kNdS3S8ZlZtIEF7NTELqX/QUHtONFO6WtOK+RoFkPEnFDrg7aS8p6KPYYuARG5Kgjs0L+lQmkaCAUJuK0Ez4EhUBzDwVYUA9FCvUSJJEO9Em0/EMNDItTaXi
YAfkAE2ocaCmGTY0Wls3+gkDexbSzB5o3pE9KTqyLxxBCtgTycWSjGw9Yh99Mx3tz94UyHA4Cc0wHBYaS5XWL3WjGdGTsPkzYmOH83Wz2DTYXzeIVGv7knad
3Lnp2rdyNu8aRictG6TlDfMtM086ONLBNo+sMvzoaW709H3S/wgtXFRVsrp6yRi2sDFAFVRrgPBrqvuQGfi1CrwK6i6qsBXdwajAQUgcIZMsScEYPERINh1s
MEozsG4/A8lE3cRJuKrQnBYHAwxUfzKdcI8hPbknk2DBjWOLW7IvrBJ3PAPc6UyMGumkv0Iq9xqx+oETNcguBCMaDkdhI5Aglw5n0lFoPzjS6egURWUFzULk
htCYzaRSUL8MBa5FUjD8QAuN0XdVwAWF0GiCbtCK5JmREHhbCpq04Cizj5KRFnQV0ho0kXLNz7CzZ/jZEDcb4mcpbpZaJ+MbtQ0bFusbJ2+dfP3UJiR8P/H9
jL79/O3YO1fevsK3jnKto/mmw+uWsa91RJ0NtBMV5oKWEZtLjCiYS+lolTKDiqZTNq572jj0eNWMVyNYwwuRm/NU0VUFexZkohRNy351OTNPC81SbyxzPZ3K
RGIwA1zNUTRDg97yxQWdaIUbqxYV6cyNWLukFeVJF0e61sk2TLdzZPs62YHpLo7suk/2iKukLnkAo7RKi2q0SiF5QMm5PPLzptVBVU+BjnWjaOxXV/PvNuGo
+j1d70ADQUTgYyeg64VImdNUzenZTqpSTC5oPQefMrsvrQuoim757FKCmqA2qAuoQ7Lj+huU5heyy+M7tpfmb1YYKxwDmn5DWg9n2KIwQ1d1X1BfDBiluRFK
aFWQ0F7dF0BPptttLs9j7UTII3EYiAi0tbRhG5mdCjL1QUNRWhss7NKGMUOicIyr4sSIpo2Iq7yGGUF/aubE1MzRRd1A/77+Aa9B0NOwFMvRgkVyHFwumRlY
oaXC8zcZihZMOZqKSXQcUFSBrsG5KwxjDg1rOaFWPEXxLZxjoovO45J3umnkTm58aWJ+pLN/bzwJ53p40V5kiUdgdhWTtA0gZ8OBTAykLbKfV4VJOP9LOSaZ
EkyxJH0lnKPhJABSr6CBhRvQYiqduQ50mILzEoxJOgNj5QJM8ChzC8ZiwpcDOCw1b0SpLKpRBSMlkV4Sx2RBBy+PUmAU03ilBC1aI0GLHlEwZCM3UbARNPBK
GjmZu/ATQ00/us5Vnt8r4hnSMv3vBAo5m7UNbyR/kvyLK0uaL+rst218214O/jfvXTnzYeiDED8U4oZC+ebQhqVhS11nHN10d/Hu53/nfn5l14d9H/Txe33c
Xl/ePc1ZXEtHb9sftng23R13Jt6d/sU0v2uY2zWcd49stO3aaPWg//aeTU/vu8/93XPv3Xj/tbuv5T3DvGeS80zmPce/riObapeObTUQNfVvTN+a5i3tnKX9
Tve6pXfD07luaUOZBPc7OYuTdR1cPbN2jhs5dd8y+91mneMRoTKOyizoUtbzg9UO3nKIsxz645YGjj6hkQI+1Y1rJ0zEZybTxAHNZ3bnxJDmsyEdpMsyD9In
jqm/JVBMbX2GUkWOr6hkMVePFyvXgm/K8avgm+ptqliFQgaWReoK31SPqROFY4lvqmcWe8YzuVQMFhXQGygmesmdpdIxVIkUXNONchr2EL1oufYCQ1nSA+NE
oRIFE4ipXSkzhuMgsxAu2KlXI5q1Fl/uQ6anEY1WtNc9WEyZvSrJPIAuDGCb3VJrjbYNW9NbB988yNsGOdtg3ja0NLFlIow1PNnPkf3LzH1y33ePjYS1+RGh
hszlhnHiXnTd4t+0NPxxSwdHnzxCof5HLf3Er0xjrZpV51iz5tNmHSTL8iwp2cQJFa5GitkTZ7xnKWZDch1StIaiHoMqG3G8EVkCtjtDtQSo9WJpCZVllmxJ
zrnQlk5BCZptZqVgl7DAVftrFPo10r1Gik9cj+epzK8tZtohjTRLbJuaMc3JNFGgSqxTN7PYNpmBeBpan5JZ9tDeIv6C54VwjkhYH7spADIAEoI6GRP0sBOC
Zq+hJNxqUlRasIcVzEo0Z2zJmgTFeLXYVsEp3IHGsb0iDy0G2Bk02P8nDbbMCdBt6FsEst4v6qA5NlQUzwc+Hl/T/mZqnZz83Na0NL6l1tTkVBtWO2/1clbv
csfvrf3f6Ai747vHesLugsFOHH7L/KaZt3Zy1k62y3dv/F+P/fMx9szcv5zkT6TZCxf5CzHuQoylFvIX0myW5q3M76wMioXw4iePEKT8df2YSf2pyXSkQ/ep
fUx7pFX321YdPFG2+481ot0rVeEB1bZWoswvWzCMd+poLcT2RcyvCapqiUGZg1JtI0OrcM/Wp9xTp8CvR/ywxlHXEgHDoAZ6jPK15DM+n1F+Pm3R2+fGJS9P
60KyX0MfJYmqn1LVHNTJ1+uV9y8U9zpMc11FTwwqQla/TeFuRR0ZQo1yr0GpHoa1a2FmOXjM3gqYg0b4R4bk+jgor75/p8IcLcUNvZBTnpVCjQorc7c87qke
l++iUMvKlXltvz5t2mYluqv75npLojPh76nm8PdV95U8u7yS/5X1mLs9pC25+/fVet1cT9BYvBKiW+sMQBkUnIHNoqM0oC4kaRoF3SSskYX6DEgmkmlYBqM9
qXQE7Z3KFErj4Sfa/vlkWrCl0M5Fxb5nTdnponuiAI4rYjoK3EwGBnzBOH7q5OyJo2eOTgiq84JqD6w/9hFoQ6KwgXopQl8SjGgCpSSdXKTARcRXg6RCCAHr
dwQM+kufK5pZyKYoppgr0GX4iGVDyrsDvISkWPy5NAIXR1EyASivAbTQ4h4cspuS/RexPJJRf2k+QQYlaEDkOjiEKAOgsqkIrOaRGnBJj6ECOIyaMcTRFIle
gQgCprMEVSYJxWe02RzNgLJbeM1ijpqTNAnOI876smUPoxlE8CrGKJqBysRggwQUnc2kaQqgjU1BH0sm4CiYgic0KgncxZ+Y684SVcWZUho9iQT8UIX3MOrt
bzW92XTb/lMXXz94p+Pd7l90L/fkPYNL+g1z/a1Dmw2tbPuLqx2rjWzbaL7hMGs5/NDZ9s6xt4+xu/bnnT/gnfHV8U+OfXRszZcfPgXP2OA59uWL7LkIG6HY
QJwlmzck/hfzzgO88+xq9JPER4m1TP7QWTi66ehl+4bzjtmlms0dPcv293fe3cl7xzjv2NpIfsfpJfPD8tR7cPXsOnm6rJxcmczbDi6Rm86BFceHzg+cq6/m
ndMc2bxk3NITTZ6Nne0bzW0bzZ7Njq47P8x3DPEdI1zHyGpsbTzfcXzT1fnO+b89/17P+8/dfS7v2s+7xjjXWN41/nWNwWZaMmxZYZkqTqCPI/uWJ9fJYVQN
GG4ZeNLDkZ6/H7xz7ZcHf61ZOZkfOMIP+LgB37197Okz+YEg+9KFdTJZUTtM3XuBfSV2n6SqN9D+D8KY3dvBGMkd3dImXCmWaZawTPVWXQWg6dp2U08R1Rjl
UQVoM4AFKlaKVdKRQ9LInSV843C+Y37bzDtGOMdI3jG6NFXAN4McObgydZ88tD2+Oc0Gzq5bXioCHHCaqNg0/H8w8z8IZnZVgZlK40SIpm+iolOCNYXTUmiD
87ZgKoxU4BsQRIPN4e1sqwhycKqTQM51QkxQIo8C0LmGGAa/n/mWOUcWyfolIaIdRzXaGf3MtnbuH1vXydnPm9xLxxDa8akx2unnrP3L139v3Q/RjqO9iHbw
cCnaCbDB83zwIhe8CNNCPhj/liDOqibU7OWr/OUcdzm3RRDXVePqRwRxBXbDs0n1lBoy1fvU3+AW4yCf+gl4mahwDWSi2DV+rP1fgHeedk+5wtwW1eifEUEZ
tkVQJEZQRoigSvHOvv9GvKNXclApTUmziTQiLKJUD1dyxpAO6uUnMwX1FTrQK2EiSYrfXj0GUVKTPHN5ZdPkNmtpfkZtWmSJEFnJzztchimMT8MU0tyD5NNG
D8C4EKkvs8+aoBHqtjZIwhXSlqwQ4XdWy/G3Kcy9rgyBkvDPoIiElN5pWAP1khUEGvq1GLcp4D4lvDfXFTSVId8uheu81X3fG7n9ifnO/XkZcvu+q2Wb6w6S
ZcjNXkBui7BZdFUljlL4hkERrv0xMAAXCIQOKgN2GISRqE6/eAqzDkJV7iQtVlY4f2F0l7oJRVJIUBxd0BM4PgaBE7rjQgSVX/MUejPszmWR9H43dSNLRaG4
kUXSHYkyuUhqZLE/mJUAIL4JerddmRBRskPABeU69KkLQK98YaaS74wRC362xf3ViTOXgpIrcmc11GvE+Q+gt0NKCE/QUzeSMJcJhiQtfo5RPwnbmQyD8zlG
hoIWvdESDHAFMQKtRRdjqKdLZa7DdF3MyuhDF7H6dIqrU5U18U1EoNdUAHqVLBLae03SPshIShXxnUnE4FgSXiKAemskPYiQWUfdYEBEGeK9SlQVqdtWEmgC
9Jcizmt0vhV6M3THnm/s4hvHl7vf77vbt5LK944vmTbMjZzZhZHeodXrq0m27Xi+YYq1TD10drwz/fY02zOSdx7ina+sdf9T36d99zL5I68gXGdrQRiMbdub
tw3ytmsr1z989YNX1w7k98/AM/YMxcazLHWVvZpj/dcgPCsAvWMQ6DV2L2vfN9818z2HuZ7Da7vzjbNwGnXNfJ2bq3PzdR6uzrPcy/cOc73DfO8hrhfi0A62
a/qe794wu2su33COtZx7aG9969Sbp/L2Tt6+m7PvXjJu1jtvv5yv74HY1dGGyvE7J/OOvbxjZiXx4ZUPrqz15V+YWar5wuz4eeCOK+/aw7uGONcQBH2rL3wy
/NHwbw7lXVN4Gabu7b7Xxs7BYiXBXrzEtiXzDZdZy+WHlUjuwDo5//Cp8PJro67etKTbssgocoAjB1Za7jWuk6dkHNnNkd3vaZZ9/1D3sW31Qv7Faf7FAPdi
gD0zx74MT19hL8bXSVBRlvnZM6+wV67eJwFGkgXsJPoH/gAJZQ3xxSmy8hnIgMdMkqEDo8yKTM2rFvSAYnIgLWhn8Hd3k9CjjyJ7085nMinwOuL8S9HR0Zb7
E3sK+l8olowyIViSPuceS9+8cKGMDe16PKkt5yhjQFUteAM1pb3Yff6qvLcOfQ+F9rTCYcEUDi9kYrkUoi3h8FUYtwojdWHoW4BmUsk0lc6EwwAFbIB2+/BH
Hfh1K36Hhd8L4A0TjPtw9Sw6V2M4jIJGMhqOMAxIzucYig6H7xJ4lUUntEkN2j/C77p+TDxWG3QB1eMGre6I6rFFo/P+h8mmG33ca9ANPW4gdTnVY7tDF1c9
3meAvbDDp/7WvlNHq0SxSJi3GT1YPMegDwrDACVCMIwa/AYcb0nhF9cGFHlTyXmgx1GQuZmFsRoYytVaVLWsYBQGcdgQAykOqTggovJL3AqzVn7HiD97E5/3
b1Ajr4FADosqOAR+Dk9RhqX3wwbW6SrVA8LzB6Lmc8L8JdH9gHA8IHb8G+F6QLQ9IJq+JLL3iewDYuhb/VG1ilZ9Q+ADFvyfUEsDBBQAAAAIAKuIuFwdGf3d
CwsAAC4VAAA2ABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy90dW5uZWwuY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAE
AAAAAJ0YbWxb1fXe9+HP59iJncTYTeKkSVOHNIGWgpoudGmpOseNhfIaWgjweLFfUnfOc7jvuSUBTROTNjoxtd3EAhISRSCRiklM2hDdr9EgQfvPxmh234Im
hPaj//o1hbFp2rnv2W6KHWCz5fPOO/ecc88599x7zvUtj8eF4JMf9p/4B2Bfog0fznrgW08CfAWJSMRxNI4JjlCciTOEgScbZ8dZwo5zhDPpXJwftxFbhcdO
7CIv2uKOcSdxRlAQ9SDRHkKiYzdjaSeuOSbqvEbRKDbcsqpmdVlPZ1UtEWUMR0rWFT09rxgOCpeyqgJc7Ji6eA1bEoKoZ4k8p4zNKaqe3Gg+UzX/UdP8JxHB
IgKTMWFFl8iE0AkwWXSLrInxgHEmZhMFkTcxu+gRbSbmEJuo2YA5RQeY7Ix6jZaNMx/JqaqSuSbAVEnmG0aw1IgjphHHfJna0BSe3MhZpaIM3sDBNeSoyc2h
CzhxgTV4mRph2JJZdTY9Z9gy2bk5hUAAOU3JzBIvsGpUKBIxdg7Pq/owxFUezmjyA5IpOSznUmld0iyPKjTd9GloYdFwSFJahXHJCNc7PVQd9NBZOgD8FJVb
Qy+7ym3h5cy5zEvcy+4a+iv3LWp3Em9wyAa/n9EYvQ7UrSgCcZpCk/WOo0m2niZi4G0QpUlbPa2acxHUCV+QczbgqTxjkL/HYb6T+BU2wQsgM+2r6W6pl+tB
0601m9hNbGrb3KbdeLN5J44i5ECJ/d1o2lWVsiOZoX4oaFrYQNuGkIo3iZ23niYyU/hOTLpgZhWP4UTOes7iWTTKdqFZDLMffIidxVEusRQRdZnoaXUuYqVH
ZD6bUiKzWRJRARnt05Y6rLSIpNIapKOqJHUlNawQkiUjkT7tGg+TRQXSDA+DS6sgSeNqOKm4lARgeKSKnJRVk4rRHFcWZ7IyScVUHdTkFnTDefC5pLJATwnD
qdRQu6wtqsl01uC1jKIsGOy8/JwRtMyUiFJVqlEspUUZQpfRYEFBZW8QPyW4SU6VwCzlpEKMzgb5vmGcatDeBnCNJr1DOOM+7S45ggVHMH/P3otH/jz9p+nS
6GRhdPIzh1huarnO8J7DuNwWKrVtL7RtL7YNlHwDed/AbRfyh5Z3/HrH2+z5sfP6O8+/+XypP17ojxcjh4stEyVhIi9M3PAir/8L38BXa/7eWwh72sq+1jX/
FoqCTl9g2X3OXfL1FXx9+W0HP9RKvnjBF//XdZYOf/2FMPFP7TAY+uLOAwJ+37/fgy458H43d8nJUdzzEJBXOXzAxa3yNoq7MdBXPYEDdna1e8sBhl0d5YH+
EcMD5SM7B/hdu9iNKrv4P1x1F0/fyb4GJ10P+v47V8TwZURW5PbwGMXw3XsEch432sug391AFw90Tz29uvNF2xBT3Y2b70rQ0dTA9uYG89lFxx4MEtiHWr6h
R2VAj7+B5TULVPbOuTLFTtdOkOq+3V55T8H7xP2UZ/KeBjY4a/q4KU50jRxDSN5hRijUwIuOBhrcm2gWarGqnSQTOdDMiR46yyFMT6pNZJuqsqN4ox8RNIxU
fhMZbwOZEJXZhN/3LXPYpjtrPkfqZXs2W+WeetoUX/W/QeX4/2bpbTCLbfNZ/qc90Dzdv8HySoQ20GxV2nfslZYh7rv3yiE88RMzI/w0I1IszYqJX2yafdEG
9gY2WV1/ffaB7vdpxTzEJJoA4kTZhB9AFfsEfr+FX9xFu1P41qIJnexxrmqzA1Gr51A3GmwwUssimHmWoXUzYnJbtdKsk62Ja3TJr9F6nUbIh6Kc4VmAogmd
EtSwk3LGEMxX2tRmc7rhSWaymlJ7dUDlkrT0kmLwRJFTi7SR0xcXFKPJ7MxMPimnJ49AA8xDKUoqZAjRIloruEuBShWuUWjxJTTc0BNCmTS4E9kZzWhOZU+p
maycggL5bE7RdM1w1DDBmk2DhjynRbnKHFQOiqiiGp4qp/lKhukwb1b6pfbK9Bp4q5BIrfxHWw3XKWVGyyZ/rMAM9op5Vg/gqtTqHMnQxlVNGS7phJZVJV15
Tjc4Sc2eMltZwyfNy4szinRcgV5kRpF1wy0tyAQiSNkNV1pLq2A17R64VDqpG+ycopstB6FJRLpMR/SspB+n8TW6F0g2qWharQE2fZwl2XlQu0jDY2yrstQF
7G4+NgOR2AP6o3arv3DW3DVYIp8y7POgBeaARdYgAWBVyS6T704w7TSPrY/ZkpAwZYg0ar03dkr01NZ4xmpG1vwdJX/fp/6+suA7Ez8dLwk9BaGnHNpSvidM
YWtk3W0LuG4iANcF1NRebgkvD50bOh94p+vNrov9+anHS1NPF6aeLk49U7hfLnbPFFuSJSGZF5JU4/jp8ZLQXxD6V1J5+tx1tWPbOmr1HMVnk+VQZyk0UAgN
rOwqhobP2tfau8/3/+6BUvt9hfb7rga2lMM9b+x9be/5k8Xw0FlH2R9+9fmif6AcCC7Hz8VLgd5CoDfft/fiUdo9XQ4XR6c+CzxGxYDx5Gfg0AbxUviB3/ec
faQc6X/H/aY7P7ivGPlhKSJ/eOiT+KV4/tEnio9Mw2teks/G1kLdpdC9n4buXXnkvfEL46XBY4XBY/knni6GpFJAygekOoanCoNP5aXjxVC6FEjnA+lyaOsb
Q68NrQTe67rQVY4MrfVtXwm89fhadHDl8Xc7ywND70UvRN+994aTH2m7hfiBdgDh4DriW4PXKbgpoNYO6sWpv/i31dztKwSgYdv3Yfsn4Uvhy0eLY1OlwGM3
R+naNCFPCt/kaFRvtKHWZ/DVQPoGQ9WFUWf/uh941lE9uE7Bv7WLkA4v3h8bxX/AMWz7IwvYB7tjW9lLgzjG2S7t6AJ8VeBiPm7Vh2N2x2ozS/HAgSEY+JjB
MZfjY3YnxXfjmOD4+MExAV4uw0CT4zLrprifsl7BGFRf4TDF7Rh0XHGZuGDCJhP6TNjsppxbcKyLu9Jho/hWW2zEfiWKY4PClQGB4g/agX5XZ8miyh16FqHv
0VVO13rKhnfAiuZvu/v1IkJrXpQnVJXB0nOHNzE4cw1nWsvCmTsv61FsXQCoyspO3UfZ2hvtVFDyMGWmhQG2p7f9zAu/fOH1H71x+LXDRW+05N1V8O4qenfn
HbsJbfLu+heBr0ZgwIwA9MIoTntHptGNeMrslPcwvVB1EoSqgfKBBw08AtXCpSlwUMpgn0aLinlc8qnc/IIWZa3zyl45ycgxai1rHUWWc9R+o6ORc7VTeowK
BU0X/+rwl73+M0unl17tXznx0lLeuyfv2FPvHFd1LnGXc3BB38S96hL2QoG+s9hw/WWta+wcql5co0wlAKzpjcFTxzTSYZGou2ScgnpXqR8NL38b6sxBKjVQ
9fU6wzqDZX/78si5kVeTv9mXF3q+WvMG4XrmDF71t8M9zBn8WqO39Z/72tGyq5d9C/eyhDZ+CbiL0rpGHNZi2Yii54hqcAn6fxdDJDP3NJ1ADX4C8KUmWtSm
gTAYGVMXnzIZiEyFOcshiltUugWibvoPjipD0yBBSZXgyp7LUFyQpGdzcqYy4pWk2TTR9ExaVdSsJJEAVUC7ULMAmblt5oAVnVbJ7AzSSUnWdZKeyemKJkkX
kLnCVhRdVUDd1rZS39FthuW33nY5+QS+3dbFH8W392PMP3jbxvAj6w6eD1ryLstusHY2B8EA+whdILN0m9vS2pE2KKDQSJlukhEKdlYDac0+R0HNIsPxA8v5
h8kJeKWJpHUDgNXB+HPU8yVq+hvq/xy1fYma/446120P4+BNBMBU8V9QSwMEFAAAAAgAqoi4XApngiOWAAAA2QAAABAAHABjaGVja19jb25maWcuYmF0VVQJ
AAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAF2MQQrCMBBF93OK2Qi6aBS8gHdwW5A4mbTBNhmTaWk3nl1DQdC//P+9f2HqEybvgRweHe5eTk4QPMakyEsoimbm
OLdXykG0tLJqn6LhhXEP+Ims2JyxGbFiGwwHIDsMf6YlDbNVNnersN1UTYJgiEWr0DSTdNk6ri38LBkzP6eQeeSoxejy/aCe6XGjFH3ojKwgdioMb1BLAwQU
AAAACACqiLhcjA5piscBAAArBAAADwAcAGNoZWNrX2NvbmZpZy5weVVUCQADzy8Tas8vE2p1eAsAAQQAAAAABAAAAAB9Ul1r2zAUffevEIKCDatW9ljwQ+p6
XWHYxfGehWJdJwJFMpIcKGP/fbIdpbG67MEy9+Pcj3NPb/QRDcwdpNghcRy0cejNm0k/BdjIhaPWacP2QP2nHOm06sU+5ErNOF1c/4FI4X8BUjZvxey4DZB6
vwcTABbcONDFdxsTLMG9Jdx7QIOyowEah5NkxyxQLgzK541TSnshgdKMGLBaniDNyMDMNOh5nnw1ShoKZMmZk/yajtS6jxT0FWEC6oSz7FyMCNXrFBdz7miY
E1rNcODIjl0H1vajlO84Anj26NNmW9Jfzc/8zuIvaGlHwAx0bjcaGWGq+rmkhX9WAKU5+Fk5RNnbtm42LyVt6rpdAQKFRmsXYdqy2lQtfX1eARwoppynHGmD
8H28ylOzqYofMWhnmOoO16BwMjrp1JN846LpUiDeZslCF1lMR0YnMKIXwB/R3HrVwZ9z0Wv+IdVLbS+NQSsLPrhkkQMw43bAXPobW8fcaPEjwkVdfX99oW25
bX19fE2dD0/S+BepPnMWM/XzWS+IqdID+UYe8J/PMkCXzsiBdSiMdt4pmFkyGOE3iKTGFJ/Wm1hX0M2uuUqnj4MEF6mQ+DP8BVBLAwQUAAAACACqiLhc+jEC
YSIBAADoAQAADwAcAGluc3RhbGxfZGV2LnBzMVVUCQADzy8Tas8vE2p1eAsAAQQAAAAABAAAAAB9kF9LwzAUxd/7Ka7Fhw1MQHzzbdaiwrAlqfgyKLG92wJt
EpPbsSF+d9NuqOCfQMLl5nfuPRyJxJa2UaStgfNSysZrR8JaSvQaZmezCgOxUtEWUr5Ds1sdibByB9paw3GP6XwObwnE4w7AroD1MJIw8cl7wld/KUfUaQfa
BFJdB4wNbuNVi2P3p067SfRJe/D4OmiPPRoKnPa/mY4Tvgxm1h3YA2F//IjjVO86TE/cxDx7TcjubSBIM4+KsIUJhrJDFRCw1QS5KOubhczrJ7G8gMfiNq+z
+JxKmWcir0CZFmRViMVdXouiqOAF19Yj+MEYbTY8jel8X7eUi0sISIODxo7G4m4OYjDX8H+Iaoie6kDWqw3W8RrivdImJmpNg2nyAVBLAwQUAAAACACqiLhc
Q213CvoBAACcAwAAGgAcAGluc3RhbGxfdGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAH1TTW+bQBC98ytGSI7ag6Fpb5Vc
FdnYiVo7FdBDpSjWZhnsVZZduh+ufclv7yw4qZtY4YBWw7x5b98bviLfatBNE1l0UnMmI15DWsPose4+RKIBpR3gXlgHCaodvIuAnh6VF8VN8XkoN0IiCAut
sFaoTQILVGiYw7TWf5TUrAa3ReBaNWLjDdbANqgcdIw/0Akao1sa+CPpx3fMWxyI9sJBeg+X0fsXYnZEe1tyIzpnb7uD22qV4B5PBU4NMkdqYCeM80wCQYTR
qiXmJDlSHWD8CcYthHnDVKIiH+QLCsad2NGFknvmooEvwDrRgVDWBcB47LuNYTWGavTfFwMGf3thMHDbxO1dMBziKiu/rVfZMp9kvhZuLkxbOm3Ikiz4Ew9d
2SJfVevZdTHpY4lPsNPlbMJbiozDEFwcj57bR3EMdxd3F2C8WveGB/UE51vH7IOFNFcEqlYQj56VjGL4oryEj+F90jpDiQ7PdKfz84De/rOAqniqkPxQKKdw
syqrrKggLX5C+aus8iUdv8PV9eIqL6k8D/mjMdpI3KGEy9OkK2KEkm+x9hLNk+0UPoXUMNrNOoHCK9pB2tF+V5mFrG6Fol2iNdXmrcX7d6Uw49V9ol5Cnx+E
AOGYIPQRgj3Koj8gqDxqC38AeU9n4+hsPedobeOlPCTRIOMvUEsDBBQAAAAIAKqIuFxOD3XONQAAADcAAAAQABwAcmVxdWlyZW1lbnRzLnR4dFVUCQAD0C8T
as8vE2p1eAsAAQQAAAAABAAAAAArSi0sTS0uKba1NdIzBiKugsqSjPw83ZT8ktS8MltbQz0DPUOu8tSk4vzk7FSQOkMjPQMuAFBLAwQUAAAACACqiLhcfDvP
u/wAAACUAQAADQAcAHJ1bl9hZ2VudC5iYXRVVAkAA88vE2rPLxNqdXgLAAEEAAAAAAQAAAAAXZAxT8QwDIX3/Iq3IMHQFMTGhIQQI6isJ51yiduzSJOQuKW3
8NtJr8txnixb733PfiZ7jIh9rwqJj9Z4ZR1ah5tfl+4V9whRQAsXgaYw41ah1ln12nXv3dM27tkTuGDkUjgMGm8UKBuh1sWf4KNxkCPBxtDzMGVyMAMFQTL2
q3bocxyr4Yfe7BcWtAc8qLurCHOF7T5t5iRll05yjEHTQpexXjIZqRkwc5bJeFQJ5xjGytN6A6QTmkc0I1a/zbWi6vX+CmGs8FzP0AcjauOtssQJHIqsgqaZ
0pCNo3Wq/m0yMn1PnGllFy3LpYeZHMu+SMz1AfvzO/RoOKg/UEsDBAoAAAAAAKqIuFytoohsLwAAAC8AAAATABwAcnVuX2FnZW50X2RlYnVnLmJhdFVUCQAD
zy8Tas8vE2p1eAsAAQQAAAAABAAAAABAZWNobyBvZmYKY2QgL2QgJX5kcDAKY2FsbCBydW5fYWdlbnQuYmF0CnBhdXNlClBLAwQUAAAACACqiLhcc9tLXl4A
AABmAAAADAAcAHJ1bl9vbmNlLmJhdFVUCQADzy8Tas8vE2p1eAsAAQQAAAAABAAAAAANyTEOgCAMAMC9r+jiKPoE/+BKYmqpSiKUSCVx8e063HST8KGo2wYc
cAjYvaGMwHSe6Jrk5me+YrHqiS02MnErGZTHDs3YJ6Q7RFuq6UW7LL9sLlH8r9fMAoXuKvABUEsDBAoAAAAAAKqIuFzlgx33OgAAADoAAAAYABwAc3RhcnRf
dGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAEBlY2hvIG9mZgpzY2h0YXNrcyAvUnVuIC9UTiAiQXVkaXRGaXJtU3RvcmFn
ZUFnZW50IgpwYXVzZQpQSwMECgAAAAAAqoi4XDqIHG1IAAAASAAAABkAHABzdGF0dXNfdGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAA
AAAEAAAAAEBlY2hvIG9mZgpzY2h0YXNrcyAvUXVlcnkgL1ROICJBdWRpdEZpcm1TdG9yYWdlQWdlbnQiIC9WIC9GTyBMSVNUCnBhdXNlClBLAwQKAAAAAACq
iLhcQl08MToAAAA6AAAAFwAcAHN0b3BfdGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAEBlY2hvIG9mZgpzY2h0YXNrcyAv
RW5kIC9UTiAiQXVkaXRGaXJtU3RvcmFnZUFnZW50IgpwYXVzZQpQSwMEFAAAAAgAqoi4XLi315BRAAAAdQAAABwAHAB1bmluc3RhbGxfdGFza19zY2hlZHVs
ZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAHNITc7IV8hPS+MqTs4oSSzOLlbQd81LUdAP8VNQcixNySxxyyzKDS7JL0pMT3VMT80rUVKwyyvN
UTACkUiaXFJzUktS8erTd+MqSCwtTuUCAFBLAQIeAxQAAAAIAKqIuFw8Po1A0QAAAD4BAAAMABgAAAAAAAEAAACkgQAAAAAuZW52LmV4YW1wbGVVVAUAA9Av
E2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcUizhRVgCAAA6BAAACgAYAAAAAAABAAAApIEXAQAAUkVBRE1FLnR4dFVUBQAD0C8TanV4CwABBAAA
AAAEAAAAAFBLAQIeAxQAAAAIAKqIuFyR0y7TkwMAADkHAAAjABgAAAAAAAEAAACkgbMDAABSRUFETUVfTFNBMV9MT0NBTF9TVE9SQUdFX0FHRU5ULnR4dFVU
BQADzy8TanV4CwABBAAAAAAEAAAAAFBLAQIeAwoAAAAAAKuIuFwAAAAAAAAAAAAAAAAUABgAAAAAAAAAEADtQaMHAABhdWRpdF9zdG9yYWdlX2FnZW50L1VU
BQAD0S8TanV4CwABBAAAAAAEAAAAAFBLAQIeAwoAAAAAAKqIuFxraGOJJQAAACUAAAAfABgAAAAAAAEAAACkgfEHAABhdWRpdF9zdG9yYWdlX2FnZW50L19f
aW5pdF9fLnB5VVQFAAPPLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XGFbMtTgAgAA8AsAAB0AGAAAAAAAAQAAAKSBbwgAAGF1ZGl0X3N0b3Jh
Z2VfYWdlbnQvY2xpZW50LnB5VVQFAAPPLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XGb0X0DJBAAA5g0AAB0AGAAAAAAAAQAAAKSBpgsAAGF1
ZGl0X3N0b3JhZ2VfYWdlbnQvY29uZmlnLnB5VVQFAAPQLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XIrppAA2AgAAQgkAABkAGAAAAAAAAQAA
AKSBxhAAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvZGIucHlVVAUAA88vE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcMiXO1IcBAAA/AwAAHQAYAAAA
AAABAAAApIFPEwAAYXVkaXRfc3RvcmFnZV9hZ2VudC9sb2dnZXIucHlVVAUAA88vE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhc605uolECAAB9
BQAAGwAYAAAAAAABAAAApIEtFQAAYXVkaXRfc3RvcmFnZV9hZ2VudC9tYWluLnB5VVQFAAPQLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XFPu
e8JJAwAA5AcAAB4AGAAAAAAAAQAAAKSB0xcAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvc3RvcmFnZS5weVVUBQADzy8TanV4CwABBAAAAAAEAAAAAFBLAQIeAxQA
AAAIAKqIuFx7PTmzXwUAANMOAAAnABgAAAAAAAEAAACkgXQbAABhdWRpdF9zdG9yYWdlX2FnZW50L3N0b3JhZ2VfaWRlbnRpdHkucHlVVAUAA88vE2p1eAsA
AQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcnDkQJcEGAAB6GgAAGwAYAAAAAAABAAAApIE0IQAAYXVkaXRfc3RvcmFnZV9hZ2VudC9zeW5jLnB5VVQFAAPQ
LxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XDByUh2HAwAA9QoAAB0AGAAAAAAAAQAAAKSBSigAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvdHVubmVs
LnB5VVQFAAPQLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DCgAAAAAAq4i4XAAAAAAAAAAAAAAAACAAGAAAAAAAAAAQAO1BKCwAAGF1ZGl0X3N0b3JhZ2VfYWdl
bnQvX19weWNhY2hlX18vVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4XAW4epmaAAAAvgAAADgAGAAAAAAAAAAAAKSBgiwAAGF1ZGl0
X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vX19pbml0X18uY3B5dGhvbi0zMTMucHljVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4
XNpcCab4CAAANBUAADYAGAAAAAAAAAAAAKSBji0AAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vY2xpZW50LmNweXRob24tMzEzLnB5Y1VUBQAD
0S8TanV4CwABBAAAAAAEAAAAAFBLAQIeAxQAAAAIAKuIuFz3aglpvAsAAGsWAAA2ABgAAAAAAAAAAACkgfY2AABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHlj
YWNoZV9fL2NvbmZpZy5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACriLhc5UFNhAoGAABmDwAAMgAYAAAAAAAA
AAAApIEiQwAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9kYi5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMU
AAAACACriLhcLV0uZJQDAAAOBgAANgAYAAAAAAAAAAAApIGYSQAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9sb2dnZXIuY3B5dGhvbi0zMTMu
cHljVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4XG4hMUfhBQAAvwkAADQAGAAAAAAAAAAAAKSBnE0AAGF1ZGl0X3N0b3JhZ2VfYWdl
bnQvX19weWNhY2hlX18vbWFpbi5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACriLhcWJ9BapoIAACeDwAANwAY
AAAAAAAAAAAApIHrUwAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9zdG9yYWdlLmNweXRob24tMzEzLnB5Y1VUBQAD0S8TanV4CwABBAAAAAAE
AAAAAFBLAQIeAxQAAAAIAKuIuFwpZ4K0XgsAAGoVAABAABgAAAAAAAAAAACkgfZcAABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHljYWNoZV9fL3N0b3JhZ2Vf
aWRlbnRpdHkuY3B5dGhvbi0zMTMucHljVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4XHr6MhnMEgAA6SoAADQAGAAAAAAAAAAAAKSB
zmgAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vc3luYy5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAA
CACriLhcHRn93QsLAAAuFQAANgAYAAAAAAAAAAAApIEIfAAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy90dW5uZWwuY3B5dGhvbi0zMTMucHlj
VVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XApngiOWAAAA2QAAABAAGAAAAAAAAQAAAKSBg4cAAGNoZWNrX2NvbmZpZy5iYXRVVAUA
A88vE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcjA5piscBAAArBAAADwAYAAAAAAABAAAApIFjiAAAY2hlY2tfY29uZmlnLnB5VVQFAAPPLxNq
dXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XPoxAmEiAQAA6AEAAA8AGAAAAAAAAQAAAKSBc4oAAGluc3RhbGxfZGV2LnBzMVVUBQADzy8TanV4CwAB
BAAAAAAEAAAAAFBLAQIeAxQAAAAIAKqIuFxDbXcK+gEAAJwDAAAaABgAAAAAAAEAAACkgd6LAABpbnN0YWxsX3Rhc2tfc2NoZWR1bGVyLmJhdFVUBQADzy8T
anV4CwABBAAAAAAEAAAAAFBLAQIeAxQAAAAIAKqIuFxOD3XONQAAADcAAAAQABgAAAAAAAEAAACkgSyOAAByZXF1aXJlbWVudHMudHh0VVQFAAPQLxNqdXgL
AAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XHw7z7v8AAAAlAEAAA0AGAAAAAAAAQAAAKSBq44AAHJ1bl9hZ2VudC5iYXRVVAUAA88vE2p1eAsAAQQAAAAA
BAAAAABQSwECHgMKAAAAAACqiLhcraKIbC8AAAAvAAAAEwAYAAAAAAABAAAApIHujwAAcnVuX2FnZW50X2RlYnVnLmJhdFVUBQADzy8TanV4CwABBAAAAAAE
AAAAAFBLAQIeAxQAAAAIAKqIuFxz20teXgAAAGYAAAAMABgAAAAAAAEAAACkgWqQAABydW5fb25jZS5iYXRVVAUAA88vE2p1eAsAAQQAAAAABAAAAABQSwEC
HgMKAAAAAACqiLhc5YMd9zoAAAA6AAAAGAAYAAAAAAABAAAApIEOkQAAc3RhcnRfdGFza19zY2hlZHVsZXIuYmF0VVQFAAPPLxNqdXgLAAEEAAAAAAQAAAAA
UEsBAh4DCgAAAAAAqoi4XDqIHG1IAAAASAAAABkAGAAAAAAAAQAAAKSBmpEAAHN0YXR1c190YXNrX3NjaGVkdWxlci5iYXRVVAUAA88vE2p1eAsAAQQAAAAA
BAAAAABQSwECHgMKAAAAAACqiLhcQl08MToAAAA6AAAAFwAYAAAAAAABAAAApIE1kgAAc3RvcF90YXNrX3NjaGVkdWxlci5iYXRVVAUAA88vE2p1eAsAAQQA
AAAABAAAAABQSwECHgMUAAAACACqiLhcuLfXkFEAAAB1AAAAHAAYAAAAAAABAAAApIHAkgAAdW5pbnN0YWxsX3Rhc2tfc2NoZWR1bGVyLmJhdFVUBQADzy8T
anV4CwABBAAAAAAEAAAAAFBLBQYAAAAAJQAlALsOAABnkwAAAAA=
"""
ERP_LOCAL_AGENT_VERSION = "1.0.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id: int | str | None = None, branch_id: int | str | None = None, sync_interval_seconds: int = 30, request_timeout_seconds: int = 60, tunnel_enabled: bool = True, tunnel_reconnect_seconds: int = 10) -> str:
def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id=None, branch_id=None, sync_interval_seconds: int = 30, request_timeout_seconds: int = 60, tunnel_enabled: bool = True, tunnel_reconnect_seconds: int = 10) -> str:
erp_base_url = (erp_base_url or "").strip().rstrip("/")
# Coolify/Traefik may forward the application request internally as HTTP even
# when the public ERP URL is HTTPS. Public agent packages must use HTTPS so
# tunnel.py derives a valid wss:// endpoint. Preserve explicit local test URLs.
if erp_base_url.startswith("http://"):
host = erp_base_url[7:].split("/", 1)[0].split(":", 1)[0].lower()
if host not in {"localhost", "127.0.0.1", "::1"}:
@@ -497,93 +29,32 @@ def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, stor
f"REQUEST_TIMEOUT_SECONDS={int(request_timeout_seconds or 60)}\n"
f"TUNNEL_ENABLED={str(bool(tunnel_enabled)).lower()}\n"
f"TUNNEL_RECONNECT_SECONDS={int(tunnel_reconnect_seconds or 10)}\n"
"AUTO_UPDATE=true\n"
"UPDATE_CHECK_INTERVAL_SECONDS=300\n"
)
def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes:
"""Build branch-specific LSA4 tunnel-enabled ZIP.
Partner/Branch Admin packages intentionally hide roadmap/readme text files and
.env.example. The package still contains requirements.txt because pip needs it.
"""
base = base64.b64decode(LSA4_ZIP_B64)
src = zipfile.ZipFile(io.BytesIO(base), "r")
def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes:
if not RUNTIME_ROOT.exists():
raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}")
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as dst:
replacements = {
"run_agent.bat": (
"@echo off\r\n"
"setlocal\r\n"
"cd /d \"%~dp0\"\r\n"
"if not exist \".env\" (\r\n"
" echo ERROR: .env file is missing. Generate/download the configured agent package from ERP.\r\n"
" exit /b 1\r\n"
")\r\n"
"if not exist \".venv\\Scripts\\python.exe\" (\r\n"
" echo ERROR: Python virtual environment is missing. Run install_task_scheduler.bat first.\r\n"
" exit /b 1\r\n"
")\r\n"
"\".venv\\Scripts\\python.exe\" -m audit_storage_agent.main\r\n"
).encode("utf-8"),
"install_task_scheduler.bat": (
"@echo off\r\n"
"setlocal\r\n"
"cd /d \"%~dp0\"\r\n"
"if not exist \".env\" (\r\n"
" echo ERROR: .env file is missing. Generate/download the configured agent package from ERP.\r\n"
" pause\r\n"
" exit /b 1\r\n"
")\r\n"
"if not exist \".venv\\Scripts\\python.exe\" (\r\n"
" echo Creating virtual environment...\r\n"
" py -3 -m venv .venv\r\n"
" if errorlevel 1 (\r\n"
" echo ERROR: Unable to create the Python virtual environment.\r\n"
" pause\r\n"
" exit /b 1\r\n"
" )\r\n"
")\r\n"
"\".venv\\Scripts\\python.exe\" -m pip install --upgrade pip\r\n"
"if errorlevel 1 goto :install_failed\r\n"
"\".venv\\Scripts\\python.exe\" -m pip install -r requirements.txt\r\n"
"if errorlevel 1 goto :install_failed\r\n"
"set \"AGENT_DIR=%~dp0\"\r\n"
"set \"AGENT_PYTHON=%~dp0.venv\\Scripts\\python.exe\"\r\n"
"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; $taskName='AuditFirmStorageAgent'; $agentDir=$env:AGENT_DIR.TrimEnd('\\'); $python=$env:AGENT_PYTHON; $action=New-ScheduledTaskAction -Execute $python -Argument '-m audit_storage_agent.main' -WorkingDirectory $agentDir; $trigger=New-ScheduledTaskTrigger -AtStartup; $principal=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $settings=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null; Start-ScheduledTask -TaskName $taskName\"\r\n"
"if errorlevel 1 (\r\n"
" echo ERROR: Task Scheduler installation failed. Run this file as Administrator.\r\n"
" pause\r\n"
" exit /b 1\r\n"
")\r\n"
"echo Audit Firm Storage Agent scheduled task installed and started successfully.\r\n"
"echo The agent now runs in the background; no command window needs to remain open.\r\n"
"pause\r\n"
"exit /b 0\r\n"
":install_failed\r\n"
"echo ERROR: Python dependency installation failed.\r\n"
"pause\r\n"
"exit /b 1\r\n"
).encode("utf-8"),
}
for info in src.infolist():
name = info.filename.replace("\\", "/")
lower = name.lower()
if name == ".env" or lower.endswith(".env.example"):
for path in sorted(RUNTIME_ROOT.rglob("*")):
if not path.is_file() or "__pycache__" in path.parts:
continue
if (not include_admin_readme) and lower.endswith(".txt") and lower != "requirements.txt":
continue
dst.writestr(info, replacements.get(name, src.read(info.filename)))
dst.writestr(".env", env_text)
name = path.relative_to(RUNTIME_ROOT).as_posix()
dst.writestr(name, path.read_bytes())
if include_env and env_text is not None:
dst.writestr(".env", env_text)
if include_admin_readme:
dst.writestr(
"README_AUTO_SETUP.txt",
"This LSA4 tunnel-enabled Task Scheduler agent package was generated from Audit ERP Branch Storage Auto Setup.\n"
"The .env file is pre-filled for this branch storage node.\n\n"
"Recommended setup on branch storage computer:\n"
"1. Extract this ZIP to C:\\AuditFirmStorageAgent or any fixed folder.\n"
"2. Right-click Command Prompt / PowerShell and Run as Administrator.\n"
"3. Run check_config.bat.\n"
"4. Run install_task_scheduler.bat.\n"
"5. Check Documents -> Branch Storage Dashboard in ERP for Online/Tunnel status.\n"
)
dst.writestr("README_ERP_LOCAL_AGENT.txt", f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\nStorage behaviour and D:\\AuditFirmStorage are preserved. Future compatible releases self-update securely from ERP.\n")
return buffer.getvalue()
def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes:
return _build_zip(env_text=env_text, include_env=True, include_admin_readme=include_admin_readme)
def build_agent_update_zip() -> bytes:
# Update package deliberately excludes .env, .venv, data, logs and client files.
return _build_zip(env_text=None, include_env=False, include_admin_readme=False)
@@ -0,0 +1,10 @@
@echo off
cd /d %~dp0
if not exist .venv\Scripts\python.exe (
py -3 -m venv .venv
)
call .venv\Scripts\activate.bat
python -m pip install --upgrade pip
pip install -r requirements.txt
python check_config.py
pause
@@ -0,0 +1,21 @@
from pathlib import Path
from audit_storage_agent.config import load_config
from audit_storage_agent.client import ERPClient
from audit_storage_agent.logger import setup_logger
from audit_storage_agent.storage_identity import ensure_storage_identity
base_dir = Path(__file__).resolve().parent
logger = setup_logger(base_dir)
config = load_config(str(base_dir / ".env"))
logger.info("Configuration loaded successfully")
logger.info("ERP_BASE_URL=%s", config.erp_base_url)
logger.info("NODE_CODE=%s", config.node_code)
logger.info("STORAGE_ROOT=%s", config.storage_root)
logger.info("TENANT_ID=%s", config.tenant_id or "-")
logger.info("BRANCH_ID=%s", config.branch_id or "-")
identity_path = ensure_storage_identity(config)
logger.info("Storage identity file verified: %s", identity_path)
client = ERPClient(config)
response = client.heartbeat({"status": "CONFIG_TEST", "storage_root": str(config.storage_root), "agent_version": "0.2.0"})
logger.info("ERP heartbeat test response: %s", response)
print("Configuration and ERP connection test completed successfully.")
@@ -0,0 +1,2 @@
__version__ = "1.0.0"
AGENT_NAME = "ERP Local Agent"
@@ -0,0 +1,96 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Iterable
import requests
from .config import AgentConfig
class ERPClient:
def __init__(self, config: AgentConfig):
self.config = config
self.session = requests.Session()
self.session.headers.update(config.headers)
def _url(self, path: str) -> str:
return f"{self.config.erp_base_url}{path}"
def heartbeat(self, payload: dict[str, Any]) -> dict[str, Any]:
response = self.session.post(
self._url("/documents/storage-agent/heartbeat"),
json=payload,
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
return response.json() if response.content else {"status": "ok"}
def pending_storage_jobs(self) -> list[dict[str, Any]]:
response = self.session.get(
self._url("/documents/storage-agent/jobs/pending"),
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
return data.get("jobs", [])
def download_storage_job(self, job_id: int | str):
response = self.session.get(
self._url(f"/documents/storage-agent/jobs/{job_id}/download"),
stream=True,
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
return response
def acknowledge_storage_job(self, job_id: int | str, payload: dict[str, Any]) -> dict[str, Any]:
response = self.session.post(
self._url(f"/documents/storage-agent/jobs/{job_id}/ack"),
json=payload,
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
return response.json() if response.content else {"status": "ok"}
def pending_download_requests(self) -> list[dict[str, Any]]:
response = self.session.get(
self._url("/documents/storage-agent/download-requests/pending"),
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
return data.get("requests", [])
def upload_download_request_file(self, request_id: int | str, file_path: Path, extra: dict[str, Any]) -> dict[str, Any]:
with file_path.open("rb") as handle:
files = {"file": (file_path.name, handle, "application/octet-stream")}
data = {key: str(value) for key, value in extra.items() if value is not None}
response = self.session.post(
self._url(f"/documents/storage-agent/download-requests/{request_id}/upload"),
files=files,
data=data,
timeout=max(self.config.request_timeout_seconds, 300),
)
response.raise_for_status()
return response.json() if response.content else {"status": "ok"}
def update_manifest(self) -> dict[str, Any]:
response = self.session.get(
self._url("/documents/erp-local-agent/update-manifest"),
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
return response.json()
def download_update_package(self) -> bytes:
response = self.session.get(
self._url("/documents/erp-local-agent/update-package"),
timeout=max(self.config.request_timeout_seconds, 300),
)
response.raise_for_status()
return response.content
@@ -0,0 +1,112 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from urllib.parse import urlencode
from pathlib import Path
from dotenv import load_dotenv
from . import __version__
@dataclass(frozen=True)
class AgentConfig:
erp_base_url: str
node_code: str
node_secret: str
storage_root: Path
tenant_id: str | None = None
branch_id: str | None = None
poll_interval_seconds: int = 30
download_request_interval_seconds: int = 30
heartbeat_interval_seconds: int = 60
request_timeout_seconds: int = 60
max_retries: int = 3
tunnel_enabled: bool = True
tunnel_reconnect_seconds: int = 10
auto_update: bool = True
update_check_interval_seconds: int = 300
@property
def headers(self) -> dict[str, str]:
return {
"X-Node-Code": self.node_code,
"X-Node-Secret": self.node_secret,
"User-Agent": f"ERPLocalAgent/{__version__}",
}
@property
def tunnel_url(self) -> str:
base = self.erp_base_url.rstrip("/")
if base.startswith("https://"):
ws_base = "wss://" + base[len("https://"):]
elif base.startswith("http://"):
ws_base = "ws://" + base[len("http://"):]
else:
ws_base = base
query = urlencode({"node_code": self.node_code, "node_secret": self.node_secret})
return f"{ws_base}/documents/storage-agent/tunnel?{query}"
def _get_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None or str(raw).strip() == "":
return default
return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"}
def _get_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None or str(raw).strip() == "":
return default
try:
return int(raw)
except ValueError as exc:
raise RuntimeError(f"Invalid integer for {name}: {raw!r}") from exc
def load_config(env_file: str | None = None) -> AgentConfig:
if env_file:
load_dotenv(env_file)
else:
load_dotenv()
erp_base_url = os.getenv("ERP_BASE_URL", "").rstrip("/")
node_code = os.getenv("NODE_CODE", "").strip()
node_secret = os.getenv("NODE_SECRET", "").strip()
storage_root_raw = os.getenv("STORAGE_ROOT", "").strip()
tenant_id = os.getenv("TENANT_ID", os.getenv("AUDIT_FIRM_ID", "")).strip() or None
branch_id = os.getenv("BRANCH_ID", "").strip() or None
missing = []
if not erp_base_url:
missing.append("ERP_BASE_URL")
if not node_code:
missing.append("NODE_CODE")
if not node_secret:
missing.append("NODE_SECRET")
if not storage_root_raw:
missing.append("STORAGE_ROOT")
if missing:
raise RuntimeError("Missing required configuration: " + ", ".join(missing))
storage_root = Path(storage_root_raw).expanduser().resolve()
storage_root.mkdir(parents=True, exist_ok=True)
return AgentConfig(
erp_base_url=erp_base_url,
node_code=node_code,
node_secret=node_secret,
storage_root=storage_root,
tenant_id=tenant_id,
branch_id=branch_id,
poll_interval_seconds=_get_int("POLL_INTERVAL_SECONDS", 30),
download_request_interval_seconds=_get_int("DOWNLOAD_REQUEST_INTERVAL_SECONDS", 30),
heartbeat_interval_seconds=_get_int("HEARTBEAT_INTERVAL_SECONDS", 60),
request_timeout_seconds=_get_int("REQUEST_TIMEOUT_SECONDS", 60),
max_retries=_get_int("MAX_RETRIES", 3),
tunnel_enabled=_get_bool("TUNNEL_ENABLED", True),
tunnel_reconnect_seconds=_get_int("TUNNEL_RECONNECT_SECONDS", 10),
auto_update=_get_bool("AUTO_UPDATE", True),
update_check_interval_seconds=max(60, _get_int("UPDATE_CHECK_INTERVAL_SECONDS", 300)),
)
@@ -0,0 +1,67 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from datetime import datetime, timezone
class LocalDB:
def __init__(self, db_path: Path):
db_path.parent.mkdir(parents=True, exist_ok=True)
self.db_path = db_path
self._init()
def connect(self):
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _init(self) -> None:
with self.connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS processed_storage_jobs (
job_id TEXT PRIMARY KEY,
local_path TEXT NOT NULL,
sha256 TEXT NOT NULL,
file_size INTEGER NOT NULL,
processed_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS processed_download_requests (
request_id TEXT PRIMARY KEY,
local_path TEXT NOT NULL,
sha256 TEXT NOT NULL,
file_size INTEGER NOT NULL,
processed_at TEXT NOT NULL
)
"""
)
conn.commit()
def record_storage_job(self, job_id: str, local_path: str, sha256: str, file_size: int) -> None:
with self.connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO processed_storage_jobs
(job_id, local_path, sha256, file_size, processed_at)
VALUES (?, ?, ?, ?, ?)
""",
(str(job_id), local_path, sha256, int(file_size), datetime.now(timezone.utc).isoformat()),
)
conn.commit()
def record_download_request(self, request_id: str, local_path: str, sha256: str, file_size: int) -> None:
with self.connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO processed_download_requests
(request_id, local_path, sha256, file_size, processed_at)
VALUES (?, ?, ?, ?, ?)
""",
(str(request_id), local_path, sha256, int(file_size), datetime.now(timezone.utc).isoformat()),
)
conn.commit()
@@ -0,0 +1,27 @@
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
def setup_logger(base_dir: Path | None = None) -> logging.Logger:
root = base_dir or Path.cwd()
logs_dir = root / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("audit_storage_agent")
logger.setLevel(logging.INFO)
logger.handlers.clear()
formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s")
console = logging.StreamHandler()
console.setFormatter(formatter)
logger.addHandler(console)
file_handler = RotatingFileHandler(logs_dir / "agent.log", maxBytes=2_000_000, backupCount=5, encoding="utf-8")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
@@ -0,0 +1,58 @@
from __future__ import annotations
import argparse
import asyncio
from pathlib import Path
import threading
import time
from .client import ERPClient
from .config import load_config
from .db import LocalDB
from .logger import setup_logger
from .sync import StorageAgent
from .tunnel import StorageAgentTunnel
from .updater import AgentUpdater
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="ERP Local Agent - storage and local ERP tool runtime")
parser.add_argument("--env", default=None)
parser.add_argument("--once", action="store_true")
return parser
def main() -> int:
args = build_parser().parse_args()
root = Path.cwd()
logger = setup_logger(root)
try:
config = load_config(args.env)
db = LocalDB(root / "data" / "agent.db")
client = ERPClient(config)
agent = StorageAgent(config, client, db, logger)
updater = AgentUpdater(config, client, logger, root)
if args.once:
updater.maybe_update(force=True)
agent.run_once()
return 0
def update_worker():
while True:
updater.maybe_update()
time.sleep(max(60, config.update_check_interval_seconds))
threading.Thread(target=update_worker, name="erp-local-agent-updater", daemon=True).start()
if config.tunnel_enabled:
asyncio.run(StorageAgentTunnel(agent).run_forever())
else:
agent.run_forever()
return 0
except KeyboardInterrupt:
logger.info("ERP Local Agent stopped by user")
return 0
except Exception as exc:
logger.exception("ERP Local Agent failed: %s", exc)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,62 @@
from __future__ import annotations
import hashlib
import os
import re
from pathlib import Path
from typing import BinaryIO
_SAFE_SEGMENT = re.compile(r"[^A-Za-z0-9._ -]+")
def sanitize_segment(value: str, fallback: str = "item") -> str:
value = (value or "").strip().replace("/", "_").replace("\\", "_")
value = _SAFE_SEGMENT.sub("_", value)
value = value.strip(" ._")
return value or fallback
def safe_relative_path(raw_path: str | None, fallback_filename: str) -> Path:
"""Return a safe relative path, preventing path traversal outside storage root."""
if raw_path:
parts = []
for part in Path(str(raw_path).replace("\\", "/")).parts:
if part in ("", ".", ".."):
continue
parts.append(sanitize_segment(part))
if parts:
return Path(*parts)
return Path(sanitize_segment(fallback_filename, "document.bin"))
def resolve_under_root(storage_root: Path, relative_path: Path) -> Path:
target = (storage_root / relative_path).resolve()
root = storage_root.resolve()
if os.path.commonpath([str(root), str(target)]) != str(root):
raise RuntimeError("Refusing to write/read outside storage root")
target.parent.mkdir(parents=True, exist_ok=True)
return target
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_stream_to_file(stream: BinaryIO, destination: Path) -> tuple[int, str]:
digest = hashlib.sha256()
total = 0
tmp = destination.with_suffix(destination.suffix + ".part")
with tmp.open("wb") as out:
while True:
chunk = stream.read(1024 * 1024)
if not chunk:
break
out.write(chunk)
digest.update(chunk)
total += len(chunk)
tmp.replace(destination)
return total, digest.hexdigest()
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
IDENTITY_FILENAME = ".audit_storage_node.json"
@dataclass(frozen=True)
class StorageIdentity:
erp_base_url: str
node_code: str
tenant_id: str | None
branch_id: str | None
storage_root: str
agent_identity_version: int = 1
def _normalise(value: Any) -> str:
return str(value or "").strip().rstrip("/")
def _normalise_optional(value: Any) -> str | None:
value = str(value or "").strip()
return value or None
def identity_path(storage_root: Path) -> Path:
return storage_root / IDENTITY_FILENAME
def build_identity(config) -> StorageIdentity:
return StorageIdentity(
erp_base_url=_normalise(config.erp_base_url),
node_code=_normalise(config.node_code),
tenant_id=_normalise_optional(getattr(config, "tenant_id", None)),
branch_id=_normalise_optional(getattr(config, "branch_id", None)),
storage_root=str(config.storage_root.resolve()),
)
def ensure_storage_identity(config, *, allow_initialise: bool = True) -> Path:
"""Create/validate one identity file in the selected storage root.
This protects the branch storage folder from accidentally being reused by a
different ERP node/branch, and gives the branch one stable storage root.
It does not scan the whole machine; ERP still remains the final authority
for enforcing one active node and one configured root per branch.
"""
root = config.storage_root.resolve()
root.mkdir(parents=True, exist_ok=True)
path = identity_path(root)
expected = build_identity(config)
if not path.exists():
if not allow_initialise:
raise RuntimeError(
f"Storage identity file is missing at {path}. Initialise the branch storage root first."
)
path.write_text(json.dumps(asdict(expected), indent=2), encoding="utf-8")
return path
try:
existing = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
raise RuntimeError(f"Storage identity file is unreadable/corrupt: {path}") from exc
checks = {
"erp_base_url": expected.erp_base_url,
"node_code": expected.node_code,
"tenant_id": expected.tenant_id,
"branch_id": expected.branch_id,
}
conflicts: list[str] = []
for key, expected_value in checks.items():
existing_value = existing.get(key)
# Older packages may not have tenant_id/branch_id. Allow blank existing
# values to be upgraded below, but never allow contradictory values.
if key in {"tenant_id", "branch_id"} and not existing_value:
continue
if str(existing_value or "").strip().rstrip("/") != str(expected_value or "").strip().rstrip("/"):
conflicts.append(f"{key}: existing={existing_value!r}, expected={expected_value!r}")
if conflicts:
raise RuntimeError(
"This storage folder is already linked to a different ERP storage node/branch. "
"Use the existing branch storage package or reset/change the storage root from ERP. "
+ "; ".join(conflicts)
)
# Backfill missing tenant/branch fields and refresh resolved root path.
changed = False
updated = dict(existing)
for key, value in asdict(expected).items():
if updated.get(key) in (None, "") and value not in (None, ""):
updated[key] = value
changed = True
if updated.get("storage_root") != expected.storage_root:
updated["storage_root"] = expected.storage_root
changed = True
if changed:
path.write_text(json.dumps(updated, indent=2), encoding="utf-8")
return path
@@ -0,0 +1,152 @@
from __future__ import annotations
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .client import ERPClient
from .config import AgentConfig
from .db import LocalDB
from .storage import resolve_under_root, safe_relative_path, sha256_file, sanitize_segment, write_stream_to_file
from .storage_identity import ensure_storage_identity
from . import __version__
class StorageAgent:
def __init__(self, config: AgentConfig, client: ERPClient, db: LocalDB, logger):
self.config = config
self.client = client
self.db = db
self.logger = logger
self.last_heartbeat = 0.0
self.identity_file = ensure_storage_identity(config)
self.logger.info("Storage root identity verified: %s", self.identity_file)
def run_forever(self) -> None:
self.logger.info("Starting ERP Local Agent for node=%s", self.config.node_code)
while True:
self.run_once()
time.sleep(max(5, self.config.poll_interval_seconds))
def run_once(self) -> None:
self._maybe_heartbeat()
self.process_storage_jobs()
self.process_download_requests()
def _maybe_heartbeat(self, force: bool = False) -> None:
now = time.time()
if not force and now - self.last_heartbeat < self.config.heartbeat_interval_seconds:
return
try:
total, used, free = shutil.disk_usage(self.config.storage_root)
payload = {
"status": "ONLINE",
"storage_root": str(self.config.storage_root),
"total_bytes": total,
"used_bytes": used,
"free_bytes": free,
"agent_version": __version__,
"agent_name": "ERP Local Agent",
"capabilities": ["storage"],
"agent_time_utc": datetime.now(timezone.utc).isoformat(),
}
self.client.heartbeat(payload)
self.last_heartbeat = now
self.logger.info("Heartbeat sent. free_gb=%.2f", free / (1024 ** 3))
except Exception as exc:
self.logger.exception("Heartbeat failed: %s", exc)
def process_storage_jobs(self) -> None:
try:
jobs = self.client.pending_storage_jobs()
except Exception as exc:
self.logger.exception("Could not fetch pending storage jobs: %s", exc)
return
self.process_storage_jobs_from_payload(jobs)
def process_storage_jobs_from_payload(self, jobs: list[dict[str, Any]]) -> None:
if not jobs:
return
self.logger.info("Found %s pending storage job(s)", len(jobs))
for job in jobs:
try:
self._process_storage_job(job)
except Exception as exc:
self.logger.exception("Storage job failed job=%s error=%s", job.get("id") or job.get("job_id"), exc)
def _process_storage_job(self, job: dict[str, Any]) -> None:
job_id = job.get("id") or job.get("job_id")
if job_id is None:
raise RuntimeError(f"Storage job missing id: {job}")
original_filename = job.get("original_filename") or job.get("filename") or f"job_{job_id}.bin"
relative_path_raw = job.get("local_relative_path") or job.get("relative_path")
relative_path = safe_relative_path(relative_path_raw, original_filename)
destination = resolve_under_root(self.config.storage_root, relative_path)
self.logger.info("Downloading storage job=%s to %s", job_id, destination)
response = self.client.download_storage_job(job_id)
file_size, digest = write_stream_to_file(response.raw, destination)
payload = {
"status": "COMPLETED",
"sha256_hash": digest,
"file_hash": digest,
"file_size": file_size,
"local_relative_path": str(relative_path).replace("\\", "/"),
"stored_at_utc": datetime.now(timezone.utc).isoformat(),
}
self.client.acknowledge_storage_job(job_id, payload)
self.db.record_storage_job(str(job_id), str(relative_path).replace("\\", "/"), digest, file_size)
self.logger.info("Storage job completed job=%s size=%s sha256=%s", job_id, file_size, digest)
def process_download_requests(self) -> None:
try:
requests_ = self.client.pending_download_requests()
except Exception as exc:
self.logger.exception("Could not fetch pending download requests: %s", exc)
return
self.process_download_requests_from_payload(requests_)
def process_download_requests_from_payload(self, requests_: list[dict[str, Any]]) -> None:
if not requests_:
return
self.logger.info("Found %s pending download request(s)", len(requests_))
for request in requests_:
try:
self._process_download_request(request)
except Exception as exc:
self.logger.exception("Download request failed request=%s error=%s", request.get("id") or request.get("request_id"), exc)
def _process_download_request(self, request: dict[str, Any]) -> None:
request_id = request.get("id") or request.get("request_id")
if request_id is None:
raise RuntimeError(f"Download request missing id: {request}")
relative_path_raw = request.get("local_relative_path") or request.get("relative_path")
filename = request.get("original_filename") or request.get("filename") or f"download_request_{request_id}.bin"
relative_path = safe_relative_path(relative_path_raw, filename)
local_file = resolve_under_root(self.config.storage_root, relative_path)
if not local_file.exists() or not local_file.is_file():
raise FileNotFoundError(f"Requested file is not found locally: {local_file}")
file_size = local_file.stat().st_size
digest = sha256_file(local_file)
expected_hash = request.get("sha256_hash") or request.get("file_hash")
if expected_hash and str(expected_hash).lower() != digest.lower():
raise RuntimeError(f"SHA256 mismatch before upload. expected={expected_hash} actual={digest}")
self.logger.info("Uploading file for download request=%s path=%s", request_id, local_file)
extra = {
"sha256_hash": digest,
"file_hash": digest,
"file_size": file_size,
"local_relative_path": str(relative_path).replace("\\", "/"),
}
self.client.upload_download_request_file(request_id, local_file, extra)
self.db.record_download_request(str(request_id), str(relative_path).replace("\\", "/"), digest, file_size)
self.logger.info("Download request fulfilled request=%s size=%s sha256=%s", request_id, file_size, digest)
@@ -0,0 +1,74 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Any
import websockets
from .sync import StorageAgent
class StorageAgentTunnel:
def __init__(self, agent: StorageAgent):
self.agent = agent
self.config = agent.config
self.logger = agent.logger
async def run_forever(self) -> None:
self.logger.info("Starting tunnel mode for node=%s", self.config.node_code)
while True:
try:
await self._connect_once()
except KeyboardInterrupt:
raise
except Exception as exc:
self.logger.exception("Tunnel disconnected/error: %s", exc)
await asyncio.sleep(max(5, self.config.tunnel_reconnect_seconds))
async def _connect_once(self) -> None:
async with websockets.connect(
self.config.tunnel_url,
ping_interval=30,
ping_timeout=30,
close_timeout=10,
max_size=1024 * 1024,
) as websocket:
await websocket.send(self._json_text({"type": "ready", "agent_time_utc": self._now()}))
self.agent._maybe_heartbeat(force=True)
async for raw in websocket:
message = self._parse_json(raw)
if not isinstance(message, dict):
continue
msg_type = message.get("type")
if msg_type == "connected":
self.logger.info("Tunnel connected: %s", message.get("node_code"))
continue
if msg_type == "sync":
jobs = message.get("jobs") or []
requests_ = message.get("download_requests") or message.get("requests") or []
await asyncio.to_thread(self.agent.process_storage_jobs_from_payload, jobs)
await asyncio.to_thread(self.agent.process_download_requests_from_payload, requests_)
await websocket.send(self._json_text({
"type": "agent_status",
"jobs_seen": len(jobs),
"requests_seen": len(requests_),
"agent_time_utc": self._now(),
}))
continue
if msg_type == "error":
self.logger.error("Tunnel server error: %s", message.get("error"))
def _now(self) -> str:
return datetime.now(timezone.utc).isoformat()
def _json_text(self, payload: dict[str, Any]) -> str:
import json
return json.dumps(payload, separators=(",", ":"))
def _parse_json(self, raw: str) -> Any:
import json
try:
return json.loads(raw)
except Exception:
return None
@@ -0,0 +1,100 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import time
import zipfile
from . import __version__
class AgentUpdater:
def __init__(self, config, client, logger, install_dir: Path):
self.config = config
self.client = client
self.logger = logger
self.install_dir = install_dir.resolve()
self.last_check = 0.0
self.busy = False
def maybe_update(self, force: bool = False) -> bool:
if not self.config.auto_update or self.busy:
return False
now = time.time()
if not force and now - self.last_check < self.config.update_check_interval_seconds:
return False
self.last_check = now
try:
manifest = self.client.update_manifest()
latest = str(manifest.get("latest_version") or "").strip()
if not latest or latest == __version__:
return False
expected = str(manifest.get("sha256") or "").lower().strip()
package = self.client.download_update_package()
actual = hashlib.sha256(package).hexdigest().lower()
if len(expected) != 64 or actual != expected:
raise RuntimeError("ERP Local Agent update package SHA256 verification failed.")
self._stage_and_restart(latest, package)
return True
except Exception as exc:
self.logger.exception("ERP Local Agent update check failed: %s", exc)
return False
def _stage_and_restart(self, latest: str, package: bytes) -> None:
self.busy = True
updates = self.install_dir / "updates"
updates.mkdir(parents=True, exist_ok=True)
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
package_path = updates / f"ERP_Local_Agent_{safe}.zip"
package_path.write_bytes(package)
staged = updates / f"staged_{safe}"
shutil.rmtree(staged, ignore_errors=True)
staged.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(package_path, "r") as archive:
archive.extractall(staged)
if not (staged / "erp_local_agent" / "__init__.py").exists():
raise RuntimeError("ERP Local Agent update package is incomplete.")
script_path = updates / f"apply_{safe}.ps1"
script_path.write_text(self._powershell_update_script(staged), encoding="utf-8")
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
subprocess.Popen(
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path), "-ParentPid", str(os.getpid())],
cwd=str(self.install_dir), creationflags=flags, close_fds=True,
)
self.logger.warning("ERP Local Agent update %s staged; restarting.", latest)
os._exit(0)
def _powershell_update_script(self, staged: Path) -> str:
install = str(self.install_dir).replace("'", "''")
stage = str(staged.resolve()).replace("'", "''")
lines = [
"param([int]$ParentPid)",
'$ErrorActionPreference = "Stop"',
"$InstallDir = '" + install + "'",
"$StagedDir = '" + stage + "'",
"$TaskName = 'ERP Local Agent'",
"$BackupDir = Join-Path $InstallDir ('updates\\backup_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))",
"try {",
" if ($ParentPid -gt 0) { try { Wait-Process -Id $ParentPid -Timeout 120 -ErrorAction SilentlyContinue } catch {} }",
" try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {}",
" New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null",
" if (Test-Path (Join-Path $InstallDir 'erp_local_agent')) { Copy-Item (Join-Path $InstallDir 'erp_local_agent') -Destination $BackupDir -Recurse -Force }",
" Remove-Item (Join-Path $InstallDir 'erp_local_agent') -Recurse -Force -ErrorAction SilentlyContinue",
" Copy-Item (Join-Path $StagedDir 'erp_local_agent') -Destination $InstallDir -Recurse -Force",
" foreach ($Item in @('requirements.txt','run_agent.bat','run_once.bat','check_config.py','check_config.bat','start_task_scheduler.bat','stop_task_scheduler.bat','status_task_scheduler.bat','install_task_scheduler.bat','uninstall_task_scheduler.bat')) { $Source=Join-Path $StagedDir $Item; if (Test-Path $Source) { Copy-Item $Source -Destination (Join-Path $InstallDir $Item) -Force } }",
" $Python = Join-Path $InstallDir '.venv\\Scripts\\python.exe'",
" if (-not (Test-Path $Python)) { throw 'ERP Local Agent virtual environment is missing.' }",
" & $Python -m pip install -r (Join-Path $InstallDir 'requirements.txt')",
" if ($LASTEXITCODE -ne 0) { throw 'Dependency update failed.' }",
" Start-ScheduledTask -TaskName $TaskName",
"} catch {",
" try { if (Test-Path (Join-Path $BackupDir 'erp_local_agent')) { Remove-Item (Join-Path $InstallDir 'erp_local_agent') -Recurse -Force -ErrorAction SilentlyContinue; Copy-Item (Join-Path $BackupDir 'erp_local_agent') -Destination $InstallDir -Recurse -Force }; Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {}",
" New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') | Out-Null",
" Add-Content -Path (Join-Path $InstallDir 'logs\\update_error.log') -Value ((Get-Date).ToString('s') + ' ' + $_.Exception.Message)",
" exit 1",
"}",
]
return "\r\n".join(lines) + "\r\n"
@@ -0,0 +1,21 @@
@echo off
setlocal
cd /d "%~dp0"
if not exist ".env" ( echo ERROR: .env file is missing. Download the configured ERP Local Agent package from ERP. & pause & exit /b 1 )
if not exist ".venv\Scripts\python.exe" ( py -3 -m venv .venv & if errorlevel 1 ( echo ERROR: Unable to create Python virtual environment. & pause & exit /b 1 ) )
".venv\Scripts\python.exe" -m pip install --upgrade pip
if errorlevel 1 goto :failed
".venv\Scripts\python.exe" -m pip install -r requirements.txt
if errorlevel 1 goto :failed
set "AGENT_DIR=%~dp0"
set "AGENT_PYTHON=%~dp0.venv\Scripts\python.exe"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHON; $a=New-ScheduledTaskAction -Execute $p -Argument '-m erp_local_agent.main' -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $s -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'"
if errorlevel 1 goto :failed
echo ERP Local Agent installed and started successfully.
echo Future compatible releases will update automatically from ERP.
pause
exit /b 0
:failed
echo ERROR: ERP Local Agent installation failed. Run this file as Administrator.
pause
exit /b 1
@@ -0,0 +1,3 @@
requests==2.32.3
python-dotenv==1.0.1
websockets==12.0
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
".venv\Scripts\python.exe" -m erp_local_agent.main
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
".venv\Scripts\python.exe" -m erp_local_agent.main --once
@@ -0,0 +1,2 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-ScheduledTask -TaskName 'ERP Local Agent'"
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Get-ScheduledTask -TaskName 'ERP Local Agent' | Get-ScheduledTaskInfo | Format-List *"
pause
@@ -0,0 +1,2 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Stop-ScheduledTask -TaskName 'ERP Local Agent'"
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue"
pause
@@ -3,9 +3,10 @@
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900">Branch Local Storage</h1>
<p class="text-sm text-slate-500">Partner/branch controlled storage setup for engagement document streaming.</p>
<h1 class="text-2xl font-bold text-slate-900">ERP Local Agent</h1>
<p class="text-sm text-slate-500">Branch-local ERP runtime. Existing storage remains unchanged; future Tally/GST/local tools will use the same agent.</p>
<p class="text-xs text-slate-400 mt-1">Scope: {{ storage_scope_title or '-' }}</p>
<p class="text-xs text-emerald-700 mt-1">ERP Local Agent release: {{ erp_local_agent_version or '-' }} · automatic updates enabled</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/documents" class="px-3 py-2 rounded-xl bg-slate-100 text-slate-700 text-sm font-semibold">Back to Documents</a>
@@ -41,13 +42,13 @@
{% if request.query_params.get('created') == '1' %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
Storage node created. Use the <strong>Download Agent Package</strong> button in the node row to generate the branch package. Secret and standalone .env are not displayed.
Storage node created. Use the <strong>Download ERP Local Agent</strong> button in the node row to generate the branch package. Secret and standalone .env are not displayed.
</div>
{% endif %}
{% if request.query_params.get('error') == 'env_download_disabled' %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Standalone .env download is disabled for security. Download the pre-configured agent ZIP instead.
Standalone .env download is disabled for security. Download the pre-configured ERP Local Agent ZIP instead.
</div>
{% endif %}
@@ -79,7 +80,7 @@
</div>
<div><label class="text-sm font-medium">Local Storage Root</label><input name="storage_root_path" class="mt-1 w-full rounded-xl border p-2" value="D:\AuditFirmStorage"><p class="text-xs text-amber-700 mt-1">For a new branch setup only. Existing branch nodes keep their already configured root.</p></div>
<div><label class="text-sm font-medium">Quota GB</label><input name="quota_limit_gb" class="mt-1 w-full rounded-xl border p-2" placeholder="Optional"></div>
<button class="w-full rounded-xl bg-emerald-700 text-white py-2 font-semibold">Download / Regenerate Branch Agent Package</button>
<button class="w-full rounded-xl bg-emerald-700 text-white py-2 font-semibold">Download / Regenerate ERP Local Agent</button>
</form>
<form method="post" action="/documents/storage-nodes" class="rounded-2xl border bg-white p-5 space-y-4 shadow-sm">
@@ -123,7 +124,7 @@
<div class="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div class="p-4 border-b flex items-center justify-between">
<div>
<h2 class="font-bold text-slate-900">Configured storage nodes</h2>
<h2 class="font-bold text-slate-900">Configured ERP Local Agents</h2>
<p class="text-xs text-slate-500">Partners/Branch Managers see only their managed branch. One branch should have one active node and one fixed storage root. Existing node secrets are not shown again.</p>
</div>
</div>
@@ -147,7 +148,7 @@
<form method="post" action="/documents/storage-nodes/{{ node.id }}/download-agent-package">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="storage_root_path" value="{{ node.storage_root_path or 'D:\AuditFirmStorage' }}">
<button class="text-sm text-emerald-700 font-semibold">Download Agent Package</button>
<button class="text-sm text-emerald-700 font-semibold">Download ERP Local Agent</button>
</form>
<form method="post" action="/documents/storage-nodes/{{ node.id }}/toggle">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+33 -4
View File
@@ -1,4 +1,4 @@
from __future__ import annotations
from __future__ import annotations
from datetime import date, datetime, timezone
import asyncio
@@ -80,7 +80,7 @@ from app.modules.documents.services import (
from app.modules.services.models import ClientServiceSubscription
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.core.tenancy.year_control import is_row_financial_year_locked
from app.modules.documents.agent_package import build_agent_env, build_preconfigured_agent_zip
from app.modules.documents.agent_package import ERP_LOCAL_AGENT_VERSION, build_agent_env, build_preconfigured_agent_zip, build_agent_update_zip
from app.modules.documents.models import BranchStorageNode
from app.modules.services.task_documents import (
@@ -1073,7 +1073,7 @@ def _default_node_name(db, tenant_id: int, branch_id: int | None) -> str:
def _agent_download_filename(node_code: str, suffix: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", node_code or "storage_node")
return f"AuditFirmStorageAgent_{safe}{suffix}"
return f"ERPLocalAgent_{safe}{suffix}"
def _find_existing_storage_node(db, tenant_id: int, branch_id: int | None) -> BranchStorageNode | None:
@@ -1226,7 +1226,7 @@ def _branch_name_map(db, branches=None):
def _storage_scope_title(user, scope) -> str:
if scope.is_system_admin:
return "All audit firms monitoring only"
return "All audit firms — monitoring only"
if scope.is_firm_admin:
return "All branches of your audit firm"
if _is_partner_branch_storage_scope(scope):
@@ -1282,6 +1282,7 @@ def storage_nodes(request: Request):
recent_download_requests=requests,
generated_secret=None,
storage_scope_title=_storage_scope_title(user, scope),
erp_local_agent_version=ERP_LOCAL_AGENT_VERSION,
can_manage_branch_storage=_can_manage_branch_storage(scope),
forced_branch_id=branch_filter,
)
@@ -1575,6 +1576,33 @@ def _agent_auth(db, request: Request, x_node_code: str | None, x_node_secret: st
return node, None
@router.get("/erp-local-agent/update-manifest")
def erp_local_agent_update_manifest(request: Request, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)):
import hashlib
db = CommonSessionLocal()
try:
node, error = _agent_auth(db, request, x_node_code, x_node_secret)
if error:
return error
package = build_agent_update_zip()
db.commit()
return {"ok": True, "agent_name": "ERP Local Agent", "latest_version": ERP_LOCAL_AGENT_VERSION, "sha256": hashlib.sha256(package).hexdigest(), "size_bytes": len(package), "download_url": "/documents/erp-local-agent/update-package", "channel": "stable"}
finally:
db.close()
@router.get("/erp-local-agent/update-package")
def erp_local_agent_update_package(request: Request, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)):
db = CommonSessionLocal()
try:
node, error = _agent_auth(db, request, x_node_code, x_node_secret)
if error:
return error
package = build_agent_update_zip()
db.commit()
filename = f"ERP_Local_Agent_{ERP_LOCAL_AGENT_VERSION}.zip"
return Response(package, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}"'})
finally:
db.close()
@router.post("/storage-agent/heartbeat")
async def storage_agent_heartbeat(request: Request, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)):
db = CommonSessionLocal()
@@ -1891,3 +1919,4 @@ def delete_document(request: Request, document_id: int, csrf_token: str = Form(.
return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?deleted=1", status_code=303)
finally:
db.close()