From 6b6cd387da613838df43ec1dc8194b7d3a057341 Mon Sep 17 00:00:00 2001 From: Sascha Leib Date: Mon, 27 Oct 2025 21:07:05 +0100 Subject: [PATCH] Stats improvements --- .gitignore | 2 +- README.md | 8 +- action.php | 2 +- admin.css | 1 + admin.js | 259 +++++++++++++++++++++++++------------ admin.php | 18 +-- captcha.js | 3 +- conf/default.php | 3 +- conf/metadata.php | 3 +- config/default-config.json | 2 +- config/known-ipranges.json | 24 ++-- img/addr.png | Bin 1974 -> 2170 bytes img/captcha.png | Bin 3382 -> 3075 bytes lang/de/lang.php | 8 +- lang/en/settings.php | 2 + 15 files changed, 221 insertions(+), 114 deletions(-) diff --git a/.gitignore b/.gitignore index cbf6a5e..a377e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ logs/*.log.txt logs/*.srv.txt logs/*.tck.txt -config/user-config.json +config/user-*.json php_errors.log diff --git a/README.md b/README.md index 547d435..5c9c822 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # DokuWiki Bot Monitoring Plugin Plugin for live-monitoring your DokuWiki instance for bot activity -IMPORTANT: This is an experimental plugin to investigate bot traffic. This is not a "install and forget" software, but rather requires that you actively look at and manage the log files. +IMPORTANT: This is an experimental plugin to investigate bot traffic. This is not a "install and forget" software, but rather requires that you actively look at and manage data for this to be useful. -This plugin creates various log files in its own "logs" directory. These files can get quite large, and you should check them actively. +In addition to collecting a lot fo information about bot activity on your server, it now also has a simple Captcha function that you can use to block off bots from downloading your precious content. It is however advisable to only activate this after you already have a better understanding of your own site's traffic patterns (both by bots and by humans) to avoid over-blocking legitimate users. -Also, these files can get quite large and fill up your server. Make sure to manually delete older files from time to time! - -For more information, please see the DokuWiki Plugin page at: https://www.dokuwiki.org/plugin:botmon +For more information, please see the DokuWiki Plugin page at: https://www.dokuwiki.org/plugin:botmon and the documentation found at: https://leib.be/sascha/projects/dokuwiki/botmon/index diff --git a/action.php b/action.php index ea97afa..c4cd017 100644 --- a/action.php +++ b/action.php @@ -251,7 +251,7 @@ class action_plugin_botmon extends DokuWiki_Action_Plugin { echo DOKU_TAB . DOKU_TAB . "cj.async=true;cj.defer=true;cj.type='text/javascript';" . NL; echo DOKU_TAB . DOKU_TAB . "cj.src='".DOKU_BASE."lib/plugins/botmon/captcha.js';" . NL; echo DOKU_TAB . DOKU_TAB . "document.getElementsByTagName('head')[0].appendChild(cj);" . NL; - echo DOKU_TAB . "});"; + echo DOKU_TAB . "});" . NL; // add the translated strings for the captcha: echo DOKU_TAB . '$BMLocales = {' . NL; diff --git a/admin.css b/admin.css index af620f0..cd22926 100644 --- a/admin.css +++ b/admin.css @@ -653,6 +653,7 @@ & { display: grid; grid-template-columns: min-content auto; + gap: .25em .5em; border-left: transparent none 0; margin: 0 .5rem .25rem 0; } diff --git a/admin.js b/admin.js index 751eb9d..d49d22a 100644 --- a/admin.js +++ b/admin.js @@ -161,6 +161,7 @@ const BotMon = { var bg = b; var sm = a; + if (a == 0 || b == 0) return '—'; if (a > b) { var bg = a; var sm = b; @@ -291,7 +292,8 @@ BotMon.live = { // shortcut to make code more readable: const model = BotMon.live.data.model; - //const timeout = 60 * 60 * 1000; // session timeout: One hour + // combine Bot networks to one visitor? + const combineNets = (BMSettings.hasOwnProperty('combineNets') ? BMSettings['combineNets'] : true);; if (visitor._type == BM_USERTYPE.KNOWN_BOT) { // known bots match by their bot ID: @@ -304,6 +306,23 @@ BotMon.live = { } } + } else if (combineNets && visitor.hasOwnProperty('_ipRange')) { // combine with other visits from the same range + + let nonRangeVisitor = null; + + for (let i=0; i { - // count visits and page views: - this.data.totalVisits += 1; - this.data.totalPageViews += v._viewCount; - + const captchaStr = v._captcha._str(); + + // count total visits and page views: + data.visits.total += 1; + data.loads.total += v._loadCount; + data.views.total += v._viewCount; + // check for typical bot aspects: let botScore = 0; if (v._type == BM_USERTYPE.KNOWN_BOT) { // known bots - this.data.bots.known += v._viewCount; + data.visits.bots += 1; + data.views.bots += v._viewCount; this.groups.knownBots.push(v); + // captcha counter + if (captchaStr == 'Y') { + data.captcha.bots_blocked += 1; + } else if (captchaStr == 'YN') { + data.captcha.bots_passed += 1; + } else if (captchaStr == 'W') { + data.captcha.bots_whitelisted += 1; + } + } else if (v._type == BM_USERTYPE.KNOWN_USER) { // known users */ - this.data.bots.users += v._viewCount; + data.visits.users += 1; + data.views.users += v._viewCount; this.groups.users.push(v); } else { @@ -627,30 +690,54 @@ BotMon.live = { v._botVal = e.val; if (e.isBot) { // likely bots + v._type = BM_USERTYPE.LIKELY_BOT; - this.data.bots.suspected += v._viewCount; + data.visits.suspected += 1; + data.views.suspected += v._viewCount; this.groups.suspectedBots.push(v); + + // captcha counter + if (captchaStr == 'Y') { + data.captcha.sus_blocked += 1; + } else if (captchaStr == 'YN') { + data.captcha.sus_passed += 1; + } else if (captchaStr == 'W') { + data.captcha.sus_whitelisted += 1; + } + } else { // probably humans + v._type = BM_USERTYPE.PROBABLY_HUMAN; - this.data.bots.human += v._viewCount; + data.visits.humans += 1; + data.views.humans += v._viewCount; + this.groups.humans.push(v); + + // captcha counter + if (captchaStr == 'Y') { + data.captcha.humans_blocked += 1; + } else if (captchaStr == 'YN') { + data.captcha.humans_passed += 1; + } } } // perform actions depending on the visitor type: if (v._type == BM_USERTYPE.KNOWN_BOT ) { /* known bots only */ + // no specific actions here. + } else if (v._type == BM_USERTYPE.LIKELY_BOT) { /* probable bots only */ // add bot views to IP range information: me.addToIpRanges(v); - } else { /* humans only */ + } else { /* registered users and probable humans */ // add browser and platform statistics: me.addBrowserPlatform(v); - // add + // add to referrer and pages lists: v._pageViews.forEach( pv => { me.addToRefererList(pv._ref); me.addToPagesList(pv.pg); @@ -1064,7 +1151,6 @@ BotMon.live = { } else { // no known IP range, let's collect necessary information: - // collect basic IP address info: if (ipType == BM_IPVERSION.IPv6) { ipSeg = ipAddr.split(':'); @@ -1604,14 +1690,7 @@ BotMon.live = { fromKnownBotIP: function(visitor) { //console.info('fromKnownBotIP()', visitor.ip); - const ipInfo = BotMon.live.data.ipRanges.match(visitor.ip); - - if (ipInfo) { - visitor._ipInKnownBotRange = true; - visitor._ipRange = ipInfo; - } - - return (ipInfo !== null); + return visitor.hasOwnProperty('_ipRange'); }, // is the page language mentioned in the client's accepted languages? @@ -1910,47 +1989,48 @@ BotMon.live = { */ make: function() { - const data = BotMon.live.data.analytics.data; - const maxItemsPerList = 5; // how many list items to show? + const useCaptcha = BMSettings.useCaptcha || false; const kNoData = '–'; // shown when data is missing + const kSeparator = ' / '; - // shortcut for neater code: + // shortcuts for neater code: const makeElement = BotMon.t._makeElement; + const data = BotMon.live.data.analytics.data; const botsVsHumans = document.getElementById('botmon__today__botsvshumans'); if (botsVsHumans) { - botsVsHumans.appendChild(makeElement('dt', {}, "Page views")); + botsVsHumans.appendChild(makeElement('dt', {}, "Bot statistics")); - for (let i = 0; i <= 5; i++) { + for (let i = 0; i <= ( useCaptcha ? 5 : 3 ); i++) { const dd = makeElement('dd'); let title = ''; let value = ''; switch(i) { case 0: - title = "Known bots:"; - value = data.bots.known || kNoData; + title = "Total (loads / views / visits):"; + value = (data.loads.total || kNoData) + kSeparator + (data.views.total || kNoData) + kSeparator + (data.visits.total || kNoData); break; case 1: - title = "Suspected bots:"; - value = data.bots.suspected || kNoData; + title = "Known bots (views / visits):"; + value = (data.views.bots || kNoData) + kSeparator + (data.visits.bots || kNoData); break; case 2: - title = "Probably humans:"; - value = data.bots.human || kNoData; + title = "Suspected bots (views / visits):"; + value = (data.visits.suspected || kNoData) + kSeparator + (data.views.suspected || kNoData) break; case 3: - title = "Registered users:"; - value = data.bots.users || kNoData; + title = "Bots-humans ratio (views / visits):"; + value = BotMon.t._getRatio(data.views.suspected + data.views.bots, data.views.users + data.views.humans, 100) + kSeparator + BotMon.t._getRatio(data.visits.suspected + data.visits.bots, data.visits.users + data.visits.humans, 100); break; case 4: - title = "Total:"; - value = data.totalPageViews || kNoData; + title = "Known bots blocked / passed / whitelisted:"; + value = data.captcha.bots_blocked + kSeparator + data.captcha.bots_passed + kSeparator + data.captcha.bots_whitelisted; break; case 5: - title = "Bots-humans ratio:"; - value = BotMon.t._getRatio(data.bots.suspected + data.bots.known, data.bots.users + data.bots.human, 100); + title = "Suspected bots blocked / passed / whitelisted:"; + value = data.captcha.sus_blocked + kSeparator + data.captcha.sus_passed + kSeparator + data.captcha.sus_whitelisted; break; default: console.warn(`Unknown list type ${i}.`); @@ -2007,7 +2087,7 @@ BotMon.live = { const wmoverview = document.getElementById('botmon__today__wm_overview'); if (wmoverview) { - const humanVisits = BotMon.live.data.analytics.groups.users.length + BotMon.live.data.analytics.groups.humans.length; + const humanVisits = data.views.total; const bounceRate = Math.round(100 * (BotMon.live.data.analytics.getBounceCount('users') + BotMon.live.data.analytics.getBounceCount('humans')) / humanVisits); wmoverview.appendChild(makeElement('dt', {}, "Humans’ metrics")); @@ -2017,20 +2097,20 @@ BotMon.live = { let value = ''; switch(i) { case 0: - title = "Registered users’ page views:"; - value = data.bots.users || kNoData; + title = "Registered users (views / visits):"; + value = (data.views.users || kNoData) + kSeparator + (data.visits.users || kNoData); break; case 1: - title = "“Probably humans” page views:"; - value = data.bots.human || kNoData; + title = "Probably humans (views / visits):"; + value = (data.views.humans || kNoData) + kSeparator + (data.visits.humans || kNoData); break; case 2: title = "Total human page views:"; - value = (data.bots.users + data.bots.human) || kNoData; + value = (data.views.users + data.views.humans) || kNoData; break; case 3: title = "Total human visits:"; - value = humanVisits || kNoData; + value = data.views.total || kNoData; break; case 4: title = "Humans’ bounce rate:"; @@ -2292,6 +2372,10 @@ BotMon.live = { const sumClass = ( !data._seenBy || data._seenBy.indexOf(BM_LOGTYPE.SERVER) < 0 ? 'noServer' : 'hasServer'); + // combine with other networks? + const combineNets = (BMSettings.hasOwnProperty('combineNets') ? BMSettings['combineNets'] : true) + && data.hasOwnProperty('_ipRange'); + const li = make('li'); // root list item const details = make('details'); const summary = make('summary', { @@ -2301,17 +2385,17 @@ BotMon.live = { const span1 = make('span'); /* left-hand group */ - if (data._type !== BM_USERTYPE.KNOWN_BOT) { /* No platform/client for bots */ - span1.appendChild(make('span', { /* Platform */ + /*if (data._type !== BM_USERTYPE.KNOWN_BOT) { // No platform/client for bots // disabled because no longer relevant + span1.appendChild(make('span', { // Platform 'class': 'icon_only platform pf_' + (data._platform ? data._platform.id : 'unknown'), 'title': "Platform: " + platformName }, platformName)); - span1.appendChild(make('span', { /* Client */ + span1.appendChild(make('span', { // Client 'class': 'icon_only client client cl_' + (data._client ? data._client.id : 'unknown'), 'title': "Client: " + clientName }, clientName)); - } + }*/ // identifier: if (data._type == BM_USERTYPE.KNOWN_BOT) { /* Bot only */ @@ -2331,16 +2415,22 @@ BotMon.live = { } else { /* others */ - - span1.appendChild(make('span', { // IP-Address - 'class': 'has_icon ipaddr ip' + ipType, - 'title': "IP-Address: " + data.ip - }, data.ip)); + if (combineNets) { - /*span1.appendChild(make('span', { // Internal ID - 'class': 'has_icon session typ_' + data.typ, - 'title': "ID: " + data.id - }, data.id));*/ + const ispName = BotMon.live.data.ipRanges.getOwner( data._ipRange.g ) || data._ipRange.g; + + span1.appendChild(make('span', { // IP-Address + 'class': 'has_icon ipaddr ipnet', + 'title': "IP-Range: " + data._ipRange.g + }, ispName)); + + } else { + + span1.appendChild(make('span', { // IP-Address + 'class': 'has_icon ipaddr ip' + ipType, + 'title': "IP-Address: " + data.ip + }, data.ip)); + } } span1.appendChild(make('span', { /* page views */ @@ -2362,6 +2452,7 @@ BotMon.live = { const span2 = make('span'); /* right-hand group */ // country flag: + if (!combineNets) { // not for combined networks if (data.geo && data.geo !== 'ZZ') { span2.appendChild(make('span', { 'class': 'icon_only country ctry_' + data.geo.toLowerCase(), @@ -2369,21 +2460,22 @@ BotMon.live = { 'title': "Country: " + ( data._country || "Unknown") }, ( data._country || "Unknown") )); } + } - span2.appendChild(make('span', { // seen-by icon: - 'class': 'icon_only seenby sb_' + data._seenBy.join(''), - 'title': "Seen by: " + data._seenBy.join('+') - }, data._seenBy.join(', '))); + span2.appendChild(make('span', { // seen-by icon: + 'class': 'icon_only seenby sb_' + data._seenBy.join(''), + 'title': "Seen by: " + data._seenBy.join('+') + }, data._seenBy.join(', '))); - // captcha status: - const cCode = ( data._captcha ? data._captcha._str() : ''); - if (cCode !== '') { - const cTitle = model._makeCaptchaTitle(data._captcha) - span2.appendChild(make('span', { // captcha status - 'class': 'icon_only captcha cap_' + cCode, - 'title': "Captcha-status: " + cTitle - }, cTitle)); - } + // captcha status: + const cCode = ( data._captcha ? data._captcha._str() : ''); + if (cCode !== '') { + const cTitle = model._makeCaptchaTitle(data._captcha) + span2.appendChild(make('span', { // captcha status + 'class': 'icon_only captcha cap_' + cCode, + 'title': "Captcha-status: " + cTitle + }, cTitle)); + } summary.appendChild(span2); @@ -2569,6 +2661,13 @@ BotMon.live = { dl.appendChild(evalDd); } } + + // for debugging only. Disable on production: + dl.appendChild(make('dt', {}, "Debug info:")); + const dbgDd = make('dd', {'class': 'debug'}); + dbgDd.innerHTML = '
' + JSON.stringify(data, null, 4) + '
'; + dl.appendChild(dbgDd); + // return the element to add to the UI: return dl; }, diff --git a/admin.php b/admin.php index 7a3c3a0..e9eda49 100644 --- a/admin.php +++ b/admin.php @@ -43,7 +43,7 @@ class admin_plugin_botmon extends AdminPlugin { $pluginPath = $conf['basedir'] . 'lib/plugins/' . $this->getPluginName(); /* Plugin Headline */ - echo '
+ echo NL . '

Bot Monitoring Plugin

'; } diff --git a/captcha.js b/captcha.js index f95ddcd..9f32475 100644 --- a/captcha.js +++ b/captcha.js @@ -178,7 +178,8 @@ const $BMCaptcha = { const hash = $BMCaptcha.digest.hash(dat.join('|')); // set the cookie: - document.cookie = "DWConfirm=" + hash + ';path=/;'; + document.cookie = "DWConfirm=" + encodeURIComponent(hash) + ';path=/;hostOnly;session;sameSite=strict;' + + (document.location.protocol === 'https:' ? 'secure;' : ''); } catch (err) { console.error(err); diff --git a/conf/default.php b/conf/default.php index 45cf90c..4ae29b2 100644 --- a/conf/default.php +++ b/conf/default.php @@ -5,7 +5,8 @@ * @author Sascha Leib */ -$conf['showday'] = 'yesterday'; +$conf['showday'] = 'today'; +$conf['combineNets'] = true; $conf['geoiplib'] = 'disabled'; $conf['useCaptcha'] = 'disabled'; $conf['captchaSeed'] = 'c53bc5f94929451987efa6c768d8856b'; diff --git a/conf/metadata.php b/conf/metadata.php index 64e2a86..de95acc 100644 --- a/conf/metadata.php +++ b/conf/metadata.php @@ -8,10 +8,11 @@ $meta['showday'] = array('multichoice', '_choices' => array ('yesterday', 'today')); +$meta['combineNets'] = array('onoff'); + $meta['geoiplib'] = array('multichoice', '_choices' => array ('disabled', 'phpgeoip')); -//$meta['useCaptcha'] = array('onoff'); $meta['useCaptcha'] = array('multichoice', '_choices' => array ('disabled', 'loremipsum', 'dada')); $meta['captchaSeed'] = array('string'); diff --git a/config/default-config.json b/config/default-config.json index 8134cc5..e7aab9e 100644 --- a/config/default-config.json +++ b/config/default-config.json @@ -70,7 +70,7 @@ "bot": 30 }, {"func": "blockedByCaptcha", "params": [], - "id": "blockedByCaptcha", "desc": "Visitor was blocked by captcha", + "id": "blockedByCaptcha", "desc": "Visitor did not solve the captcha", "bot": 20 }, {"func": "whitelistedByCaptcha", "params": [], diff --git a/config/known-ipranges.json b/config/known-ipranges.json index 68dcf4f..9d2956b 100644 --- a/config/known-ipranges.json +++ b/config/known-ipranges.json @@ -1,23 +1,23 @@ { "groups": [ - {"id": "alibaba", "name": "Alibaba"}, - {"id": "amazon", "name": "Amazon"}, + {"id": "alibaba", "name": "Alibaba Network"}, + {"id": "amazon", "name": "Amazon Data Centres"}, {"id": "bezeq", "name": "Bezeq Int."}, {"id": "brasilnet", "name": "BrasilNet"}, - {"id": "charter", "name": "Charter Inc."}, - {"id": "chinanet", "name": "Chinanet"}, - {"id": "cloudflare", "name": "Cloudflare Inc."}, - {"id": "cnisp", "name": "China ISP"}, + {"id": "charter", "name": "Charter Inc. Range"}, + {"id": "chinanet", "name": "ChinaNet"}, + {"id": "cloudflare", "name": "Cloudflare Network"}, + {"id": "cnisp", "name": "China ISP Range"}, {"id": "cnmob", "name": "China Mobile"}, - {"id": "google", "name": "Google LLC"}, + {"id": "google", "name": "Google LLC Network"}, {"id": "hetzner", "name": "Hetzner US"}, - {"id": "huawei", "name": "Huawei"}, + {"id": "huawei", "name": "Huawei Network"}, {"id": "misc_sa", "name": "Misc. SA ISPs"}, - {"id": "tencent", "name": "Tencent"}, + {"id": "tencent", "name": "Tencent Network"}, {"id": "unicom", "name": "China Unicom"}, {"id": "vnpt", "name": "Vietnam Telecom"}, - {"id": "vdsina", "name": "VDSina NL"}, - {"id": "zenlayer", "name": "Zenlayer"} + {"id": "vdsina", "name": "VDSina Network"}, + {"id": "zenlayer", "name": "Zenlayer Network"} ], "ranges": [ {"from": "1.92.0.0", "to": "1.95.255.254", "m": 14, "g": "huawei"}, @@ -56,6 +56,7 @@ {"from": "111.119.192.0", "to": "111.119.255.254", "m": 18, "g": "huawei"}, {"from": "113.160.0.0", "to": "113.191.255.254", "m": 11, "g": "vnpt"}, {"from": "114.208.0.0", "to": "114.223.255.254", "m": 12, "g": "unicom"}, + {"from": "114.119.0.0", "to": "114.119.255.254", "m": 11, "g": "huawei"}, {"from": "114.224.0.0", "to": "114.255.255.254", "m": 11, "g": "unicom"}, {"from": "119.8.0.0", "to": "119.8.255.254", "m": 16, "g": "huawei"}, {"from": "119.13.0.0", "to": "119.13.255.254", "m": 16, "g": "huawei"}, @@ -64,6 +65,7 @@ {"from": "122.9.0.0", "to": "122.9.255.254", "m": 16, "g": "huawei"}, {"from": "123.16.0.0", "to": "123.31.255.254", "m": 12, "g": "vnpt"}, {"from": "124.243.128.0", "to": "124.243.191.254", "m": 18, "g": "huawei"}, + {"from": "136.107.0.0", "to": "136.125.255.254", "m": "+", "g": "google"}, {"from": "138.59.0.0", "to": "138.59.225.254", "m": 16, "g": "misc_sa"}, {"from": "138.121.0.0", "to": "138.121.225.254", "m": 16, "g": "misc_sa"}, {"from": "142.147.128.0", "to": "1142.147.255.254", "m": 17, "g": "w2obj"}, diff --git a/img/addr.png b/img/addr.png index 417982df3c6dc95760d22415e442e2642723b638..6ced1499c5b939f3db5b268caad2d60fff11ff8b 100644 GIT binary patch delta 1899 zcmV-x2bB1>5Bd<0$q5rO0s!a(000w_(qDgVDh~GIbo}oC00${aL_t(&L+zP&tQ18Y z$M1oFy+u^)Mvc8I8nH!GVxqCc2=*35eX+*&gg~sQU_&txjlH3@^0B zk_aLSD%kMz*`0ar?c2NC$K8wn_)R`Hvoo{v+v(-nDj5yGVD$Mo?XW=-(0zRcx88p? z_R?|bW2Mq*fXoNsyqZ2Wtw9)hT~F4)O*m)4*Knm_OP~?39*oDp*#<1-PtY>KokQfr z;P00B$l*8|ht zUxDzg`jF~V$Hu8z!rmNY^m3f%g2|mOUyvFZt;Ku7dgPM@- zxjVd02?oV|QS3z@6F3or>*9C&_$F)#aisqY-;(CX^f9QTeMf$OC;WZ<4o(dudDa>S zcCnYiQS3Bs3FUp5NhSy2rs{uaUx(jU;JoBHr!oUgeF!JR$mFR~xhlBo3aB}kl{H|s z+zAf6hhSU2d|B85zoq>N3RTuXs*ZNo4eNLgI0IY<_e0h6kX2a&`;z&uxb|_s+Du)b zc7<4Ng(_=cHcSS;*tW2rUjr|}*03V1!$;WTs+8Tn*V7E@|H$A^)zN>x3BQ}ckmNb- z)@P*Nf7QJAF+ylFSBi5=p2kObL zdU@)tR-B8T<##`D)8q(lMtj1h@Ej%DKN=XuuLG!+8is*My%X;q13%&SBUlNn&shaH z(X9r`lDd=@rTYLpZ9acid3E~VX$i_GoiX1^9~jMJCcGW|b=6EaEv3dKNJr-l;Q80a zQ?ZK9r6kU@HPuE2jm`u+!*;2+i{6Flskc}f=-g`egf!5ov0Og;r`|4d68t9h7OPAf z4CeO{$nQ9ux{sg@UPvE{)i&V5_nZYUg`v>Tz!qo`OoGw9R~UbEy4N_zLD{LhjLd6W zSj7^!&GXAWxH%8450`*)anCP@_Nt>HyU@>vJKzg&+Hv!%7HJ^%qT#cy-DV=sRU?+L zE}eiho8vgOD5sEpvgwGw5huBjDm9XMp}&TM@d}xJjN<~MCYm{Md%*+Xe0B>YA6|B< zI20^ZESd7is_uUZu9+SMoHI}x`%&RgzP_Xagr1KvfrTkZ*s)qwTok<&p^??TF3lGA;zJtFvPEh|BiB{K67c19_eGSX{=SF|dUjugfu7wLVc@^r7x4jg z_eHcp{e4kz3q{?nh2y=1N7ab(&LZ+OnRqTJFEU;RM#3-f1q^|up|MclY=GGR%>8kY$Z-#yT{ zsLqj0g!8xaM>C__cPE9s5+#`t;H8q6s>}MEQ(U*bkaDvn`c=`IZXQ!Zo~d{k3)!M( zaXK#)apQ7nvs@b!*(+veBJwPPd*DRq8_;iRtglw6*07pAOE0; zTaAB4ig$H!d*4W1E=O7}OK@}W=jC$uHu3J1rWpXf>W^mW+J+`|xnPylnjYnbiTyWJ}8gNZ@ z^Q!(1uGQl~BGQk>B#$KCJ0Sg@OtWc{Dn&7mWJBDG#NXrb{<(}u-cT{kDnTJC9%4SGN^~u5;q0)MMB$T6#M~2nPoAJX{+lzsBW*|rPXob l`8cBDsXw8!w{%gZ@(%!DX=qPyI{^Ry002ovPDHLkV1jpiy6XS{ delta 1702 zcmV;X23h&~5VjAH$q6hn0s!a(001nJ(qDh-)TK)nRd~_>00w1AL_t(&L+zS*tejIA zhv&A`T2wS4Y73&aE>wxDmevvp2_jWnZP9A4eY-BAYOAXR<%-@)QHl~v>0d+z)u6Vv zXe&foY5GU2N-b?;d!BC@Gt+P8`=&FKM$%vMJKJ~Wyk~jOd){;IZORp39HKQ3JvrQBVc$nx)xCGv9*#_VixI3JHfU7Ln z%pk&6QM`hWol&|f(8-ZR3W(dvb&2AJVUvJVY!Z-)h6^a%$(Iss&<0Du2sY~^6NMvq z4lW9lHNiHfhRK2v2u5LkFx)#Rs2+dc!I|{_pD-H@iRGpxZ;jTd|taszWHd_@iAjl@eZ#%$UOW2L!JH6YKluOGr+hRbLeJ~y_Eny!h z&V!}jEXyN{fVpr+30Fa}7?yssEDsmi#VDse9-SVc)nX8IBx@9)2Aywd12BKZDZ0%B zTw_U{U|Y5s?A%8Goj;Hb8 ziPdp?ZKEDgN5FX65|LHFayT8@W7k4GZ-?)~gW-0t7oAY!icsjjSHgc>=Kt&{9ipve zU&7w-q#z%v_2@A9AF5k}Vw?d_i|886gcD&AHeA5Cj@RMzfT0#GAw4hnR)BH>o`#1* zcdS!$HLL_x_0BL^jMx^vNq7R(X>teGf}`Pn@GVAkVk}@7p&O{47>0mpU4!JHfeQ)0 zhTB8ub6Em4bUQ)eNIic_lj?m5y=}gnn>FX}a0FQpTFg(v47+(;$EQ)cB5G07Oh_L= zxH}(&-hW*@5l_)Ij6_RY7ELseb|*Lt_6w8i;R`q?OeRACc~`p;P(a#Zd3^Q^lj}hv z_(PaXMwT@gLii#q^*A)$Ls^5j!)!8|cDZq$R&Xpl3vOXy9k72EPJ?5*c5fc#GtO|B z)pTcpZS{pc9D#10J@?|K9ohrl426e#>2au5oeK*O`ql6W_$}1z==_Sc3W&96jqs(R1Jgs1x=Ztl1XX%SH5BH_?M%&GB(@6xQ!`hkC*mw^`g1^85Ezle^cTG=GjW05N&bp-UhqF=;FD*Ht=&{)3+0gd&G5U_Fm zB05mfFJcWU`$bWlh}PCt4-$PQ_eqifkIUQJ+uJn{iAYmZAE-QEOB)xKT?c(@DvLA$ zL!;Q&6yHJ%X_?R?EQ_=gEk0NlG7UxQ%=BrFJ&1qGzO;yj@8peZDwu}HiQ;$Ya+t{U zd7336Z3KF2Zl-eZ+aqBz8L1nMQFJ3Z1^Q0p{a5(%^%ug9|8K72De@&<^k#@fLk8-(i0g>V_2UufmWaZ;Sj_nH=N&jk w=K8S!|19Od?06X|g=Kz=Z}cK#{ao%}^~w_bPhZyR0RR9107*qoM6N<$f>ik|Z~y=R diff --git a/img/captcha.png b/img/captcha.png index 842decfa21265918eda71867c9c93a8fabd7b496..50f492bb08e5fe1f297aa13f955f9b3864632ea3 100644 GIT binary patch delta 2818 zcmV+d3;p!A8iN>+sR06!svipzG6DeT0{{RMG6Ip*U4LyV4))@7{O6i%$;}Ud1jwy zo;mj$4S&F@_nox|!3wx0B#cJqz$-U>XzjnVf7{Wi0KpF>coTd(e1d5SegV&em!Rm+ zA*BJ03J|+!W3&GPehBUB%PDYoNU4A_4H&Lyo%p{O}LTYFC~02w4A*V zWtCd6gWzDel7gQJiGcKn6MO|eCnU__4tNxKZD{~`Pb6@hc9Oo_wAjyGuzvaR#;81g|3g?ks6^2zlZlH=?fv_rPDlZ^5fg$l*8$XU^ZV zXfrw$P+JQ>86HkN4)`Gi>y3lUNdH?NZ$zr#&OF>F4+9Wz545HVeq;3oJ-_+WU72`Lcjo^${_E+o`(Ds;vF0oGf9 z0B6ih_TT7CYGmdD{O%Xzfq#34 zlsUWuy5KDP2@@hp3efxyf)8f@jn*OPm^?1N4$p97Z-U}a-MMFj2fbd2GpjClD$?gPBzjs2X zXo}mRC*B5xyFMDxsURX({CGg`Y(ikhDqyWLuE`RdewOgiv z-StIyHj1=50ty8Pzk(pxGK+vMD0#J!bgS@aTr3o_s+(OGJT|xI3`gh?3{~(A=)fPH z)3|B97^=Xj>Pq&|i3g8qy%?h4E(Yu;gqrM5S-CP#^BCz}yU0FYD1Hy?EucR=Z=+olp|RVC^lm0*a#;b@-PB8+^ZRij zA%iRSds(!G!=O8Ggm;MpQBYhOYv%z4OxECgVnb(P(CNVg4cn5AqkGXgYrpf}8wBTFx4} zLW5WfTonEy6m|QH5Pux)qSRmH-r}ugJp!^bV=~sV_KxfzKhh-Ncm#hD`Re{63RV3@ zKZe?%n<-GbwG^0)y(gRiwOOBTQjV>Z2{u-$Ai8tZ?v?0TGR+;MnXp&^1#mZn_%gp&O3bZb?3V*!1dI@}NPIp*uq`l}@ zFd2I>e>}8X?hVYQ=Ph}+bz(>hVxGBGJ2Sf-9toQVKfyE#_NK;5D(p4!Qw4Q@(PaDx z{{A5QCyax6QrIPcF^mPvl zNLwJ>HMMQbxSEVwX!@(yHqg`UF!DIdPKTEHB`j?h!K0(8@~BipBR z|9I-1On&do1*$C&+BV*IEwBXv?vl-zjGdyIEavqrsDJsg>n#wIu?{9Z@UMk#2l|UP zP`hkv$o3#7g6v0xgsJi>$~=2yZl9+7*awuX!VM zK&uyZXO8B}#x~)iTS3HR>{vON&K?o`MF{S-i<$bA=vErb}Ce z3xAuLb**>~SSHrIct>lR33f%<=;}je7hOwi-gZ$tCgayYx0FUKOo75Pi+eNi7lyP7 z=C5E*xC`%kSj?Oh^HWe3?z7j?j6w10ks?)4wttHzApQHkbz-+*?% zqveH)cQk6WS4By?aPubbXlb6b1)~0rmMM8YY^%9weMhUG2a~Z)y%1(zmzml0p}LOA zSbrCTJF#7OD*3gk3RGJl^cQJtX;oZ-fR~!sh{@P@v@bw~Tc+kOYCF*y-_fXDY8Tbs z(fED%I~qc4!Qgi^gf}`y+7*b*F6wO2A{Eu%(YgdjG!1eMtM=G$Pq;7KE$33x z@}E1=*@9Ksha0|?9S(=PIB1;66?W=Kg;Qh;Dz zyfwvB&+jzwc61zy{N^H{P=N5NU9=Ds-xsBWT-Y*;fJVD0E}X@HE{K3sL3Nf0eMBFI zUJU&L%ELz**NOfSXr3wxxv9{Y=#5azYajk$~h zB?V}A1;#hDHDuO|>_na?zKr;tOMht4`kg5k_InpWVBpsiAIq zO~(rn7EBY;+{4^^ZXm6gl1=DTfFQlP+NPQ;uFUvJsBfFjc^!d=qNM3nJfNwIQrLWb zLb@vNK+xUNc%J5Oz1`RgTLQi91Q^d-*NeUuL|Dsob{HR2^C5%m-Aqf)u77c-6gNX1 zA#3R}H@8+awg92~LqGWx)>dbfbYNxtKeWocmN8yMNdcL5ho2{^VH-cU@|`$%yr``o z3+v-nxM}g-*}S(~RvB4RhNiv98;uS?zbP%glx+j~mEoh2YsC-C(yt2}yU4@hDA+4p zMwfeb$)(6VsNgmWcrfe&x1VD+Lg4J@mS;t{Xf zMK*W1r>lM}cG3TxT{If~4``bP Uomq#wmjD0&07*qoM6N<$g3uyem;e9( delta 3128 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 St?}jn0000