├── LICENSE ├── README.md ├── txtlog ├── base_pages │ ├── footer.php │ └── header.php ├── controller │ ├── account.php │ ├── clickhouse.php │ ├── payment.php │ ├── server.php │ ├── settings.php │ ├── txtlog.php │ └── txtlogrow.php ├── core │ ├── app.php │ ├── common.php │ └── constants.php ├── database │ ├── connectionexception.php │ ├── createdb.php │ └── db.php ├── entity │ ├── account.php │ ├── payment.php │ ├── server.php │ ├── settings.php │ ├── txtlog.php │ └── txtlogrow.php ├── includes │ ├── api.php │ ├── cache.php │ ├── country.php │ ├── cron.php │ ├── dashboard.php │ ├── error.php │ ├── installhelper.php │ ├── login.php │ ├── priv.php │ └── token.php ├── model │ ├── accountdb.php │ ├── clickhousedb.php │ ├── paymentdb.php │ ├── serverdb.php │ ├── settingsdb.php │ ├── txtlogdb.php │ └── txtlogrowdb.php ├── router.php ├── scripts │ ├── faker.js │ ├── txtlog │ └── txtlog.ps1 ├── settings.php ├── tmp │ └── .gitignore └── web │ ├── account.php │ ├── accountinfo.php │ ├── admin.php │ ├── api.php │ ├── createdemo.php │ ├── cron.php │ ├── doc.php │ ├── error.php │ ├── faq.php │ ├── index.php │ ├── install.php │ ├── pricing.php │ ├── privacy.php │ ├── selfhost.php │ └── txtlog.php └── web ├── .htaccess ├── LICENSE.txt ├── css ├── bulma.min.css └── main.css ├── favicon.ico ├── images ├── admin.interface.png ├── apache.png ├── bulma.png ├── clickhouse.png ├── github.png ├── installation.checks.png ├── jquery.png ├── lightdark.png ├── mysql.png ├── php.png ├── rdp.txtlog.png ├── redis.png └── txtlog.jpg ├── index.php └── scripts ├── jquery-3.7.1.min.js └── main.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 WillieBeek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # txtlog 2 | Txtlog.net main repository 3 | 4 | ## License 5 | See LICENSE file. 6 | 7 | ## Contributing 8 | Contributions to the code are appreciated. Clone the master repository and create a pull request with a bug fix, new feature, etc. Make sure there are no merge conflicts for the change to be approved. 9 | -------------------------------------------------------------------------------- /txtlog/base_pages/footer.php: -------------------------------------------------------------------------------- 1 | get()->getEmail() ?? ''; 12 | $hasPricing = (new Settings)->get()->getPro1AccountID() > 1; 13 | } catch(Exception $e) { 14 | // Cache not reachable 15 | } 16 | ?> 17 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /txtlog/base_pages/header.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <?=$this->getPageTitle() ?? ''?> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 20 | -------------------------------------------------------------------------------- /txtlog/controller/account.php: -------------------------------------------------------------------------------- 1 | get($this->cacheName); 29 | 30 | if($accountData) { 31 | return $accountData; 32 | } 33 | } 34 | 35 | // Fetch all accounts from the database 36 | $accounts = parent::getAll(); 37 | 38 | $accountData = []; 39 | foreach($accounts as $a) { 40 | $accountData[$a->getID()] = $a; 41 | } 42 | 43 | $cache->set($this->cacheName, $accountData); 44 | 45 | return $accountData; 46 | } 47 | 48 | 49 | /** 50 | * Get an account based on the provided ID, preferably from the cache 51 | * 52 | * @param id 53 | * @return Account object 54 | */ 55 | public function get($id) { 56 | $cache = new Cache(); 57 | 58 | $accountData = $cache->get($this->cacheName); 59 | 60 | if($accountData) { 61 | return $accountData[$id] ?? new AccountEntity; 62 | } 63 | 64 | $accountData = $this->getAll(); 65 | 66 | return $accountData[$id]; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /txtlog/controller/clickhouse.php: -------------------------------------------------------------------------------- 1 | get($this->cacheName); 29 | 30 | if($serverData) { 31 | return $serverData; 32 | } 33 | } 34 | 35 | // Fetch all servers from the database 36 | $servers = parent::getAll(); 37 | 38 | $serverData = []; 39 | foreach($servers as $a) { 40 | $serverData[$a->getID()] = $a; 41 | } 42 | 43 | $cache->set($this->cacheName, $serverData); 44 | 45 | return $serverData; 46 | } 47 | 48 | 49 | /** 50 | * Get a server based on the provided ID, preferably from the cache 51 | * If the ID does not exist it returns the first server (which should always exist) 52 | * 53 | * @param id of the Server 54 | * @return Server object 55 | */ 56 | public function get($id) { 57 | $cache = new Cache(); 58 | 59 | $serverData = $cache->get($this->cacheName); 60 | 61 | if($serverData) { 62 | return $serverData[$id] ?? $serverData[1]; 63 | } 64 | 65 | $serverData = $this->getAll(); 66 | 67 | return $serverData[$id] ?? $serverData[1]; 68 | } 69 | 70 | 71 | /** 72 | * Get a random sever 73 | * Note: this has rather poor performance when dealing with lots of servers 74 | * 75 | * @return Server object 76 | */ 77 | public function getRandom() { 78 | $serverData = $this->getAll(); 79 | 80 | $server = $serverData[array_rand($serverData)]; 81 | 82 | if($server->getActive()) { 83 | return $server; 84 | } else { 85 | $active = false; 86 | foreach($serverData as $server) { 87 | if($server->getActive()) { 88 | $active = true; 89 | } 90 | } 91 | 92 | if($active) { 93 | // There is at least one active server 94 | return $this->getRandom(); 95 | } else { 96 | // There are no active servers 97 | throw new exception('ERROR_NO_SERVER_AVAILABLE'); 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /txtlog/controller/settings.php: -------------------------------------------------------------------------------- 1 | get($cacheName); 24 | 25 | if($appSettings) { 26 | return $appSettings; 27 | } 28 | } 29 | 30 | // Fetch from the database 31 | try { 32 | $appSettings = parent::get(); 33 | 34 | // Store data in the cache 35 | $cache->set($cacheName, $appSettings); 36 | } catch(Exception $e) { 37 | // 503 Service Unavailable 38 | $httpCode = 503; 39 | Common::setHttpResponseCode($httpCode); 40 | // The settings are required. Without these, stop processing the request 41 | exit; 42 | } 43 | 44 | return $appSettings; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /txtlog/controller/txtlog.php: -------------------------------------------------------------------------------- 1 | getByID($id, $useCache); 40 | } 41 | 42 | 43 | /** 44 | * Get a single Txtlog by ID 45 | * 46 | * @param Txtlog ID 47 | * @param useCache optional boolean, set to false to ignore cache 48 | * @return Txtlog object 49 | */ 50 | public function getByID($id, $useCache=true) { 51 | $cache = new Cache(); 52 | 53 | if($useCache) { 54 | $cacheTxtlog = $cache->get("txtlog.$id"); 55 | 56 | if($cacheTxtlog) { 57 | return $cacheTxtlog; 58 | } 59 | } 60 | 61 | $cacheTxtlog = $this->get($id); 62 | if(!empty($cacheTxtlog->getID())) { 63 | $cache->set("txtlog.$id", $cacheTxtlog, 30); 64 | } 65 | 66 | return $cacheTxtlog; 67 | } 68 | 69 | 70 | /** 71 | * Get a single Txtlog by username 72 | * 73 | * @param username 74 | * @param useCache optional boolean, set to false to ignore cache 75 | * @return Txtlog object 76 | */ 77 | public function getByUsername($username, $useCache=true) { 78 | $txtlog = new TxtlogEntity(); 79 | if(strlen($username) < 1) { 80 | return $txtlog; 81 | } 82 | 83 | $txtlog->setUsername($username); 84 | $userHash = $txtlog->getUserHash(); 85 | 86 | return $this->getByUserHash($userHash, $useCache); 87 | } 88 | 89 | 90 | /** 91 | * Get a single Txtlog by userHash 92 | * 93 | * @param userHash 94 | * @param useCache optional boolean, set to false to ignore cache 95 | * @return Txtlog object 96 | */ 97 | public function getByUserHash($userHash, $useCache=true) { 98 | $cache = new Cache(); 99 | $txtlog = new TxtlogEntity(); 100 | 101 | if(strlen($userHash) != 16) { 102 | return $txtlog; 103 | } 104 | 105 | if($useCache) { 106 | $cacheTxtlog = $cache->get("txtlog.$userHash"); 107 | 108 | if($cacheTxtlog) { 109 | return $cacheTxtlog; 110 | } 111 | } 112 | 113 | $cacheTxtlog = $this->get(null, $userHash); 114 | if(!empty($cacheTxtlog->getID())) { 115 | $cache->set("txtlog.$userHash", $cacheTxtlog, 30); 116 | } 117 | 118 | return $cacheTxtlog; 119 | } 120 | 121 | 122 | /** 123 | * Update an existing Txtlog 124 | * 125 | * @param txtlog object with the new data 126 | * @return void 127 | */ 128 | public function update($txtlog) { 129 | parent::update($txtlog); 130 | 131 | // Update cached info (note: use a shared cache or custom cache invalidation when using multiple webservers) 132 | $cache = new Cache(); 133 | $cache->del("txtlog.{$txtlog->getUserHash()}"); 134 | $cache->del("txtlog.{$txtlog->getID()}"); 135 | 136 | $this->getByID($txtlog->getID(), useCache: false); 137 | } 138 | 139 | 140 | /** 141 | * Delete a Txtlog 142 | * 143 | * @param txtlog object 144 | * @return void 145 | */ 146 | public function delete($txtlog) { 147 | parent::delete($txtlog); 148 | 149 | // Remove from the cache 150 | (new Cache)->del('txtlog.'.$txtlog->getID()); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /txtlog/controller/txtlogrow.php: -------------------------------------------------------------------------------- 1 | get($cacheName); 21 | 22 | if($rowCount) { 23 | return $rowCount; 24 | } 25 | 26 | $rowCount = parent::count($txtlogID); 27 | 28 | $cache->set($cacheName, $rowCount, 60); 29 | 30 | return $rowCount; 31 | } 32 | 33 | 34 | /** 35 | * Get all TxtlogRow rows for a given Txtlog ID 36 | * 37 | * @param txtlogID 38 | * @param limit, retrieve max this number of rows 39 | * @param search object with search keys and values 40 | * @param timeout cancel the query after this many seconds (fractions are possible) and return the partial results 41 | * @return array of TxtlogRow objects 42 | */ 43 | public function get($txtlogID, $limit, $search, $timeout) { 44 | // Make sure limit is an integer and within a valid range 45 | $max = Constants::getMaxRows() ?? 1000; 46 | $realLimit = Common::isInt($limit, 1, $max) ? $limit : $max; 47 | 48 | return parent::get($txtlogID, $realLimit, $search, $timeout); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /txtlog/core/app.php: -------------------------------------------------------------------------------- 1 | register(); 147 | 148 | // Check access, an IP whitelist can be provided in app/settings.php 149 | $this->checkAccess(); 150 | 151 | // Setup requested page and parameters 152 | if(!$this->init()) { 153 | $this->showHeader(); 154 | $this->error404(); 155 | $this->showFooter(); 156 | } 157 | 158 | // Harden security with extra headers 159 | $this->setSecurityHeaders(); 160 | } 161 | 162 | 163 | /** 164 | * Set the page to load and determine all the relevant parameters 165 | * 166 | * @return bool false if the file cannot be found or determined 167 | */ 168 | private function init() { 169 | // Check if a router file is provided 170 | if(!is_file($this->getWebdir().'/txtlog/router.php')) { 171 | return false; 172 | } 173 | require $this->getWebdir().'/txtlog/router.php'; 174 | 175 | if(!is_array($routers)) { 176 | return false; 177 | } 178 | 179 | $request = $_SERVER['REQUEST_URI']; 180 | $path = explode('/', trim(parse_url($request)['path'] ?? '', '/'))[0] ?: 'index'; 181 | 182 | // Syntax: 'api'=>['title'=>'API', 'header'=>false, 'footer'=>false], 183 | $u = $routers[$path] ?? null; 184 | 185 | $path = $this->getWebdir().'/txtlog/web/'.($u['filename'] ?? $path).'.php'; 186 | if(is_null($u) || !file_exists($path)) { 187 | return false; 188 | } 189 | 190 | // Page options 191 | $this->pagetitle = $u['title'] ?? ''; 192 | $this->header = $u['header'] ?? $this->header; 193 | $this->footer = $u['footer'] ?? $this->footer; 194 | $this->allowFrame = $u['frame'] ?? $this->allowFrame; 195 | $this->fullPath = $path; 196 | 197 | return true; 198 | } 199 | 200 | 201 | /** 202 | * Return disk path and filename of the requested page 203 | * 204 | * @return string 205 | */ 206 | public function getPage() { 207 | return $this->fullPath; 208 | } 209 | 210 | 211 | /** 212 | * Get the page title, defined by a router entry or the default if none is specified in the settings 213 | * 214 | * @return string containing the page title 215 | */ 216 | public function getPageTitle() { 217 | if($this->pagetitle) { 218 | return $this->pagetitle; 219 | } 220 | 221 | // No page title found in the routers, get the default title 222 | $pagetitle = Constants::getPageTitle(); 223 | 224 | return $pagetitle; 225 | } 226 | 227 | 228 | /** 229 | * Check if access to the page is granted based on a possible IP whitelist 230 | * 231 | * @return string 232 | */ 233 | private function checkAccess() { 234 | $trustedIPs = Constants::getTrustedIPs(); 235 | 236 | // Check if a whitelist is provided 237 | if(empty($trustedIPs)) { 238 | return true; 239 | } 240 | 241 | $remoteIp = Common::getIP(); 242 | 243 | // Check if the IP address of the remote client exactly matches one in the trusted IP array 244 | if(in_array($remoteIp, $trustedIPs)) { 245 | return true; 246 | } 247 | 248 | // Check if the remote client IPv6 starts with a valid part 249 | $part = Common::get64FromIPv6($remoteIp); 250 | if(in_array($part->ipFrom, $trustedIPs)) { 251 | return true; 252 | } 253 | 254 | // Forbidden 255 | Common::setHttpResponseCode(403); 256 | exit; 257 | } 258 | 259 | 260 | /** 261 | * Set extra security headers 262 | * 263 | * @return void 264 | */ 265 | private function setSecurityHeaders() { 266 | if(!$this->allowFrame) { 267 | header('X-Frame-Options: DENY'); 268 | } 269 | } 270 | 271 | 272 | /** 273 | * Get the webdir, i.e. ../../ from this file 274 | * 275 | * @return string 276 | */ 277 | public function getWebdir() { 278 | return self::$webdir; 279 | } 280 | 281 | 282 | /** 283 | * Echoes the (HTML) header to the client, if the header is required for this page in the router 284 | * 285 | * @param force set to true to force showing the header, even if it is disabled in the router 286 | * @return void 287 | */ 288 | public function showHeader($force=false) { 289 | if($this->header || $force) { 290 | $headerPage = $this->getWebdir().'/txtlog/base_pages/header.php'; 291 | 292 | if(is_file($headerPage)) { 293 | require $headerPage; 294 | } 295 | } 296 | } 297 | 298 | 299 | /** 300 | * Echoes the (html) footer to the client 301 | * 302 | * @param force set to true to force showing the footer, even if it is disabled in the router 303 | * @return void 304 | */ 305 | public function showFooter($force=false) { 306 | if($this->footer || $force) { 307 | $footerPage = $this->getWebdir().'/txtlog/base_pages/footer.php'; 308 | 309 | if(is_file($footerPage)) { 310 | require $footerPage; 311 | } 312 | } 313 | } 314 | 315 | 316 | /** 317 | * Set a 404-Not Found error and show an error page if it exists 318 | * 319 | * @return void 320 | */ 321 | public function error404() { 322 | Common::setHttpResponseCode(404); 323 | 324 | if(is_file($this->getWebdir().'/txtlog/web/error.php')) { 325 | require $this->getWebdir().'/txtlog/web/error.php'; 326 | } 327 | 328 | exit; 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /txtlog/core/constants.php: -------------------------------------------------------------------------------- 1 | getMessage(); 38 | } catch(Exception $e) { 39 | $msg = $e->getMessage(); 40 | } 41 | 42 | if(!empty($msg)) { 43 | throw new Exception("fail: $msg"); 44 | } 45 | } 46 | 47 | 48 | /** 49 | * Create MySQL database tables 50 | * Run testConnection first! 51 | * 52 | * @param dbh PDO database handler 53 | * @return array with created database table names 54 | */ 55 | public function createSQLTables($dbh) { 56 | $sql = $this->getTableSQL(); 57 | 58 | if(!$dbh instanceof PDO) { 59 | throw new Exception('Error connecting to the database'); 60 | } 61 | 62 | $results = []; 63 | 64 | foreach($sql as $name=>$sql) { 65 | if($dbh->exec($sql) === false) { 66 | throw new Exception("Error creating table $name with the following sql: ".Common::getString($sql)); 67 | } 68 | $results[] = $name; 69 | } 70 | 71 | return $results; 72 | } 73 | 74 | 75 | /** 76 | * Create Clickhouse database tables 77 | * Run testConnection first! 78 | * 79 | * @param dbh PDO database handler 80 | * @param default (maximum) retention 81 | * @return array with created database table names 82 | */ 83 | public function createCHTables($dbh, $retention) { 84 | $sql = $this->getCHSQL($retention); 85 | 86 | if(!$dbh instanceof PDO) { 87 | throw new Exception('Error connecting to the database'); 88 | } 89 | 90 | $results = []; 91 | 92 | foreach($sql as $name=>$sql) { 93 | if($dbh->exec($sql) === false) { 94 | throw new Exception("Error creating table $name with the following sql: ".Common::getString($sql)); 95 | } 96 | $results[] = $name; 97 | } 98 | 99 | return $results; 100 | } 101 | 102 | 103 | /** 104 | * Generate SQL with all tables to create 105 | * 106 | * @return array with queries to create SQL tables 107 | */ 108 | private function getTableSQL() { 109 | $sql['Account'] = 110 | 'CREATE TABLE IF NOT EXISTS Account ( 111 | ID tinyint unsigned NOT NULL AUTO_INCREMENT, 112 | CreationDate timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 113 | ModifyDate timestamp NULL ON UPDATE CURRENT_TIMESTAMP, 114 | Name varchar(64) NOT NULL, 115 | QueryTimeout DECIMAL(5,3) DEFAULT NULL, 116 | MaxIPUsage int unsigned DEFAULT 0, 117 | MaxRetention int unsigned DEFAULT 0, 118 | MaxRows int unsigned DEFAULT 0, 119 | MaxRowSize int unsigned DEFAULT 0, 120 | DashboardRows int unsigned DEFAULT 250, 121 | DashboardRowsSearch int unsigned DEFAULT 100, 122 | Price int unsigned DEFAULT NULL, 123 | PaymentLink varchar(1024) DEFAULT NULL, 124 | PRIMARY KEY (ID), 125 | UNIQUE INDEX Account_Name (Name) 126 | ) ENGINE=InnoDB;'; 127 | 128 | $sql['Server'] = 129 | 'CREATE TABLE IF NOT EXISTS Server ( 130 | ID tinyint unsigned NOT NULL, 131 | CreationDate timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 132 | ModifyDate timestamp NULL ON UPDATE CURRENT_TIMESTAMP, 133 | Active bool DEFAULT TRUE, 134 | Hostname varbinary(512) NOT NULL, 135 | DBName varchar(255) NOT NULL, 136 | Port int unsigned DEFAULT 9004, 137 | Username varchar(255) NOT NULL, 138 | Password varchar(255) NOT NULL, 139 | Options varchar(1024) DEFAULT NULL, 140 | PRIMARY KEY (ID) 141 | ) ENGINE=InnoDB;'; 142 | 143 | $sql['Settings'] = 144 | 'CREATE TABLE IF NOT EXISTS Settings ( 145 | ID tinyint unsigned NOT NULL AUTO_INCREMENT, 146 | CreationDate timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 147 | ModifyDate timestamp NULL ON UPDATE CURRENT_TIMESTAMP, 148 | AnonymousAccountID tinyint unsigned NOT NULL, 149 | Pro1AccountID tinyint unsigned DEFAULT NULL, 150 | Pro2AccountID tinyint unsigned DEFAULT NULL, 151 | PaymentApiUrl varchar(1024) DEFAULT NULL, 152 | PaymentApiKey varchar(1024) DEFAULT NULL, 153 | Sitename varchar(255) DEFAULT NULL, 154 | IncomingLogDomain varchar(255) DEFAULT NULL, 155 | Email varchar(255) DEFAULT NULL, 156 | CronEnabled bool DEFAULT TRUE, 157 | CronInsert int DEFAULT 0, 158 | TempDir varchar(1024) DEFAULT NULL, 159 | AdminUser varchar(255) DEFAULT NULL, 160 | AdminPassword varchar(255) DEFAULT NULL, 161 | DemoAdminToken varchar(255) DEFAULT NULL, 162 | DemoViewURL varchar(255) DEFAULT NULL, 163 | DemoDashboardURL varchar(255) DEFAULT NULL, 164 | MaxRetention int unsigned DEFAULT 0, 165 | MaxAPICallsLog int unsigned DEFAULT 0, 166 | MaxAPICallsGet int unsigned DEFAULT 0, 167 | MaxAPIFails int unsigned DEFAULT 0, 168 | PRIMARY KEY (ID), 169 | CONSTRAINT FK_Settings_AnonymousAccount FOREIGN KEY (AnonymousAccountID) REFERENCES Account (ID) ON DELETE NO ACTION ON UPDATE NO ACTION, 170 | CONSTRAINT FK_Settings_Pro1Account FOREIGN KEY (Pro1AccountID) REFERENCES Account (ID) ON DELETE NO ACTION ON UPDATE NO ACTION, 171 | CONSTRAINT FK_Settings_Pro2Account FOREIGN KEY (Pro2AccountID) REFERENCES Account (ID) ON DELETE NO ACTION ON UPDATE NO ACTION 172 | ) ENGINE=InnoDB;'; 173 | 174 | $sql['Txtlog'] = 175 | 'CREATE TABLE IF NOT EXISTS Txtlog ( 176 | ID bigint unsigned NOT NULL, 177 | ModifyDate timestamp NULL ON UPDATE CURRENT_TIMESTAMP, 178 | AccountID tinyint unsigned NOT NULL, 179 | ServerID tinyint unsigned NOT NULL, 180 | Retention int unsigned NOT NULL, 181 | Name varchar(255) DEFAULT NULL, 182 | IPAddress varbinary(16) DEFAULT NULL, 183 | Userhash binary(8) DEFAULT NULL, 184 | Password varchar(255) DEFAULT NULL, 185 | Tokens text DEFAULT NULL, 186 | PRIMARY KEY (ID), 187 | UNIQUE INDEX Txtlog_Userhash (Userhash), 188 | CONSTRAINT FK_Txtlog_Account FOREIGN KEY (AccountID) REFERENCES Account (ID) ON DELETE CASCADE ON UPDATE NO ACTION, 189 | CONSTRAINT FK_Txtlog_Server FOREIGN KEY (ServerID) REFERENCES Server (ID) ON DELETE CASCADE ON UPDATE NO ACTION 190 | ) ENGINE=InnoDB COMPRESSION="zlib";'; 191 | 192 | $sql['Payment'] = 193 | 'CREATE TABLE IF NOT EXISTS Payment ( 194 | ID int unsigned NOT NULL AUTO_INCREMENT, 195 | CreationDate timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 196 | SessionID varchar(255) NOT NULL, 197 | Data mediumblob DEFAULT NULL, 198 | PRIMARY KEY (ID), 199 | UNIQUE INDEX Payment_SessionID (SessionID) 200 | ) ENGINE=InnoDB COMPRESSION="zlib";'; 201 | 202 | return $sql; 203 | } 204 | 205 | 206 | /** 207 | * Generate SQL for clickhouse tables 208 | * 209 | * @param default (maximum) retention 210 | * @return array with queries to create SQL tables 211 | */ 212 | private function getCHSQL($retention) { 213 | $sql['TxtlogRow'] = 214 | "CREATE TABLE IF NOT EXISTS TxtlogRow ( 215 | TxtlogID UInt64 CODEC(ZSTD), 216 | ID FixedString(12) CODEC(ZSTD), 217 | Date Date CODEC(Delta, ZSTD), 218 | SearchFields String CODEC(ZSTD), 219 | Data String CODEC(ZSTD), 220 | INDEX TxtlogRow_SearchFields lower(SearchFields) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 2, 221 | INDEX TxtlogRow_Data lower(Data) TYPE tokenbf_v1(32768, 4, 0) GRANULARITY 1 222 | ) 223 | ENGINE = MergeTree 224 | PRIMARY KEY (TxtlogID, ID) 225 | TTL Date + INTERVAL $retention DAY 226 | "; 227 | 228 | return $sql; 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /txtlog/database/db.php: -------------------------------------------------------------------------------- 1 | dbh = new PDO("mysql:host=$dbhost;port=$dbport;dbname=$dbname", $dbuser, $dbpass, $options); 44 | } catch(PDOException $e) { 45 | $failed = true; 46 | } catch(Exception $e) { 47 | $failed = true; 48 | } 49 | 50 | if($failed) { 51 | file_put_contents('/tmp/db.pdoexception', date('d-m-y H:i:s')."\nFunction arguments=\n".print_r(func_get_args(), true)."\n".print_r($e, true), FILE_APPEND); 52 | if($exitOnError) { 53 | // 500 Internal Server Error 54 | $httpCode = 500; 55 | Common::setHttpResponseCode($httpCode); 56 | exit; 57 | } else { 58 | // Handle in the caller function, don't show the exception to the client because it may contain confidential information 59 | throw new ConnectionException("Database connection failed: {$e->getMessage()}"); 60 | } 61 | } 62 | 63 | return $this->dbh; 64 | } 65 | 66 | 67 | /** 68 | * Close the database connection 69 | * 70 | * @return void 71 | */ 72 | public function __destruct() { 73 | $this->dbh = null; 74 | } 75 | 76 | 77 | /** 78 | * Execute any kind of SQL query 79 | * 80 | * @param query to execute 81 | * @param params optional parameters to use in the query 82 | * @param error throw this error message when an error occurs, instead of logging it and throwing the generic message 83 | * @return array 84 | */ 85 | public function execute($query, $params=null, $error=null) { 86 | $result = null; 87 | 88 | if(empty($query)) { 89 | return; 90 | } 91 | 92 | // Parameters need to be passed in an array 93 | if(!empty($params) && !is_array($params)) { 94 | $params = [$params]; 95 | } 96 | 97 | // Initialize the connection 98 | if(!$this->dbh) { 99 | $this->open(); 100 | } 101 | 102 | try { 103 | if(!($stmt = $this->dbh->prepare($query))) { 104 | throw new Exception('Cannot prepare sql query'); 105 | } 106 | 107 | // Check for parameters to bind 108 | if(empty($params)) { 109 | // No arguments, just execute the query 110 | $exec_result = $stmt->execute(); 111 | } else { 112 | // Columns can be nullable ENUMs where an empty string is not valid, so always convert empty strings to NULL 113 | foreach($params as $key=>$value) { 114 | if(is_string($value) && $value == '') { 115 | $params[$key] = null; 116 | } 117 | } 118 | $exec_result = $stmt->execute($params); 119 | } 120 | 121 | $result = $stmt->fetchAll(PDO::FETCH_OBJ); 122 | 123 | if(!$exec_result) { 124 | throw new Exception('Cannot execute sql statement'); 125 | } 126 | } catch(Exception $e) { 127 | if(!is_null($error)) { 128 | throw new Exception($error); 129 | } 130 | // Log a detailed error but don't show it to the client 131 | $this->logError($stmt, $e, $query, $params); 132 | throw new Exception('ERROR_UNKNOWN'); 133 | } catch(PDOException $e) { 134 | // Log a detailed error but don't show it to the client 135 | $this->logError($stmt, $e, $query, $params); 136 | throw new Exception('ERROR_UNKNOWN'); 137 | } 138 | 139 | return $result; 140 | } 141 | 142 | 143 | /** 144 | * Get a single table row 145 | * 146 | * @param query to execute 147 | * @param params optional parameters to use in the query 148 | * @return std object 149 | */ 150 | public function getRow($query, $params=null) { 151 | $result = $this->execute("$query LIMIT 1", $params); 152 | 153 | return $result[0] ?? null; 154 | } 155 | 156 | 157 | /** 158 | * Log a database error 159 | * 160 | * @param stmt PDO statement object 161 | * @param trace containing an exception 162 | * @param query which caused the error 163 | * @param params used in the query 164 | * @return void 165 | */ 166 | private function logError($stmt, $trace, $query, $params) { 167 | $info = ''; 168 | $info .= (new \DateTimeImmutable())->format('Y-m-d H:i:s.v')." - Database error\n"; 169 | 170 | $info .= "PDO::errorInfo():\n"; 171 | $info .= var_export($this->dbh->errorInfo(), true); 172 | 173 | $info .= "PDOStatement::errorInfo():\n"; 174 | $info .= var_export($stmt->errorInfo(), true)."\n"; 175 | 176 | $info .= "Query start:\n"; 177 | $info .= substr($query, 0, 1024)."\n"; 178 | 179 | $info .= "Params start:\n"; 180 | $info .= substr(var_export($params, true), 0, 1024)."\n"; 181 | 182 | file_put_contents('/tmp/db.pdoexception', $info, FILE_APPEND); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /txtlog/entity/account.php: -------------------------------------------------------------------------------- 1 | ID; 115 | } 116 | 117 | public function getCreationDate() { 118 | return $this->CreationDate; 119 | } 120 | 121 | public function getModifyDate() { 122 | return $this->ModifyDate; 123 | } 124 | 125 | public function getName() { 126 | return $this->Name; 127 | } 128 | 129 | public function getQueryTimeout() { 130 | return $this->QueryTimeout; 131 | } 132 | 133 | public function getMaxIPUsage() { 134 | return $this->MaxIPUsage; 135 | } 136 | 137 | public function getMaxRetention() { 138 | return $this->MaxRetention; 139 | } 140 | 141 | public function getMaxRows() { 142 | return $this->MaxRows; 143 | } 144 | 145 | public function getMaxRowSize() { 146 | return $this->MaxRowSize; 147 | } 148 | 149 | public function getDashboardRows() { 150 | return $this->DashboardRows; 151 | } 152 | 153 | public function getDashboardRowsSearch() { 154 | return $this->DashboardRowsSearch; 155 | } 156 | 157 | public function getPrice() { 158 | return $this->Price; 159 | } 160 | 161 | public function getPaymentLink() { 162 | return $this->PaymentLink; 163 | } 164 | 165 | 166 | /** 167 | * Setters 168 | */ 169 | public function setName($name) { 170 | $this->Name = $name; 171 | } 172 | 173 | public function setQueryTimeout($queryTimeout) { 174 | $this->QueryTimeout = $queryTimeout; 175 | } 176 | 177 | public function setMaxIPUsage($maxIPUsage) { 178 | $this->MaxIPUsage = $maxIPUsage; 179 | } 180 | 181 | public function setMaxRetention($maxRetention) { 182 | $this->MaxRetention = $maxRetention; 183 | } 184 | 185 | public function setMaxRows($maxRows) { 186 | $this->MaxRows = $maxRows; 187 | } 188 | 189 | public function setMaxRowSize($maxRowSize) { 190 | $this->MaxRowSize = $maxRowSize; 191 | } 192 | 193 | public function setDashboardRows($dashboardRows) { 194 | $this->DashboardRows = $dashboardRows; 195 | } 196 | 197 | public function setDashboardRowsSearch($dashboardRowsSearch) { 198 | $this->DashboardRowsSearch = $dashboardRowsSearch; 199 | } 200 | 201 | public function setPrice($price) { 202 | $this->Price = $price; 203 | } 204 | 205 | public function setPaymentLink($paymentLink) { 206 | $this->PaymentLink = $paymentLink; 207 | } 208 | 209 | 210 | /** 211 | * Fill from database 212 | */ 213 | public function setFromDB($data) { 214 | $this->ID = $data->ID ?? null; 215 | $this->CreationDate = $data->CreationDate ?? null; 216 | $this->ModifyDate = $data->ModifyDate ?? null; 217 | $this->Name = $data->Name ?? null; 218 | $this->QueryTimeout = $data->QueryTimeout ?? null; 219 | $this->MaxIPUsage = $data->MaxIPUsage ?? null; 220 | $this->MaxRetention = $data->MaxRetention ?? null; 221 | $this->MaxRows = $data->MaxRows ?? null; 222 | $this->MaxRowSize = $data->MaxRowSize ?? null; 223 | $this->DashboardRows = $data->DashboardRows ?? null; 224 | $this->DashboardRowsSearch = $data->DashboardRowsSearch ?? null; 225 | $this->Price = $data->Price ?? null; 226 | $this->PaymentLink = $data->PaymentLink ?? null; 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /txtlog/entity/payment.php: -------------------------------------------------------------------------------- 1 | ID; 42 | } 43 | 44 | public function getCreationDate() { 45 | return $this->CreationDate; 46 | } 47 | 48 | public function getSessionID() { 49 | return $this->SessionID; 50 | } 51 | 52 | public function getData() { 53 | return $this->Data; 54 | } 55 | 56 | 57 | /** 58 | * Setters 59 | */ 60 | public function setSessionID($sessionID) { 61 | $this->SessionID = $sessionID; 62 | } 63 | 64 | public function setData($data) { 65 | $this->Data = $data; 66 | } 67 | 68 | 69 | /** 70 | * Fill from database 71 | */ 72 | public function setFromDB($data) { 73 | $this->ID = $data->ID ?? null; 74 | $this->CreationDate = $data->CreationDate ?? null; 75 | $this->SessionID = $data->SessionID ?? ''; 76 | $this->Data = $data->Data ?? ''; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /txtlog/entity/server.php: -------------------------------------------------------------------------------- 1 | ID; 92 | } 93 | 94 | public function getCreationDate() { 95 | return $this->CreationDate; 96 | } 97 | 98 | public function getModifyDate() { 99 | return $this->ModifyDate; 100 | } 101 | 102 | public function getActive() { 103 | return $this->Active == true; 104 | } 105 | 106 | public function getHostname() { 107 | return $this->Hostname; 108 | } 109 | 110 | public function getDBName() { 111 | return $this->DBName; 112 | } 113 | 114 | public function getPort() { 115 | return $this->Port; 116 | } 117 | 118 | public function getUsername() { 119 | return $this->Username; 120 | } 121 | 122 | public function getPassword() { 123 | return $this->Password; 124 | } 125 | 126 | public function getOptions() { 127 | return $this->Options; 128 | } 129 | 130 | // Convert options array to string for string in the database 131 | public function getOptionsString() { 132 | return serialize($this->Options); 133 | } 134 | 135 | 136 | /** 137 | * Setters 138 | */ 139 | public function setID($ID) { 140 | $this->ID = $ID; 141 | } 142 | 143 | public function setActive($active) { 144 | $this->Active = $active; 145 | } 146 | 147 | public function setHostname($hostname) { 148 | $this->Hostname = $hostname; 149 | } 150 | 151 | public function setDBName($dbname) { 152 | $this->DBName = $dbname; 153 | } 154 | 155 | public function setPort($port) { 156 | $this->Port = $port; 157 | } 158 | 159 | public function setUsername($username) { 160 | $this->Username = $username; 161 | } 162 | 163 | public function setPassword($password) { 164 | $this->Password = $password; 165 | } 166 | 167 | public function setOptions($options) { 168 | $this->Options = $options; 169 | } 170 | 171 | 172 | /** 173 | * Fill from database 174 | */ 175 | public function setFromDB($data) { 176 | $this->ID = $data->ID ?? null; 177 | $this->CreationDate = $data->CreationDate ?? null; 178 | $this->ModifyDate = $data->ModifyDate ?? null; 179 | $this->Active = $data->Active ?? null; 180 | $this->Hostname = $data->Hostname ?? null; 181 | $this->DBName = $data->DBName ?? null; 182 | $this->Port = $data->Port ?? null; 183 | $this->Username = $data->Username ?? null; 184 | $this->Password = $data->Password ?? null; 185 | $this->Options = Common::isSerialized($data->Options) ? unserialize($data->Options) : null; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /txtlog/entity/settings.php: -------------------------------------------------------------------------------- 1 | ID; 195 | } 196 | 197 | public function getCreationDate() { 198 | return $this->CreationDate; 199 | } 200 | 201 | public function getModifyDate() { 202 | return $this->ModifyDate; 203 | } 204 | 205 | public function getAnonymousAccountID() { 206 | return $this->AnonymousAccountID; 207 | } 208 | 209 | public function getPro1AccountID() { 210 | return $this->Pro1AccountID; 211 | } 212 | 213 | public function getPro2AccountID() { 214 | return $this->Pro2AccountID; 215 | } 216 | 217 | public function getPaymentApiUrl() { 218 | return $this->PaymentApiUrl; 219 | } 220 | 221 | public function getPaymentApiKey() { 222 | return $this->PaymentApiKey; 223 | } 224 | 225 | public function getSitename() { 226 | return $this->Sitename; 227 | } 228 | 229 | public function getIncomingLogDomain() { 230 | return $this->IncomingLogDomain; 231 | } 232 | 233 | // Helper function to get the correct incoming log domain 234 | public function getLogDomain() { 235 | return empty($this->getIncomingLogDomain()) ? "https://{$this->getSitename()}" : "https://{$this->getIncomingLogDomain()}"; 236 | } 237 | 238 | public function getEmail() { 239 | return $this->Email; 240 | } 241 | 242 | public function getCronEnabled() { 243 | return $this->CronEnabled; 244 | } 245 | 246 | public function getCronInsert() { 247 | return $this->CronInsert; 248 | } 249 | 250 | public function getTempDir() { 251 | return $this->TempDir; 252 | } 253 | 254 | public function getAdminUser() { 255 | return $this->AdminUser; 256 | } 257 | 258 | public function getAdminPassword() { 259 | return $this->AdminPassword; 260 | } 261 | 262 | public function getDemoAdminToken() { 263 | return $this->DemoAdminToken; 264 | } 265 | 266 | public function getDemoViewURL() { 267 | return $this->DemoViewURL; 268 | } 269 | 270 | public function getDemoDashboardURL() { 271 | return $this->DemoDashboardURL; 272 | } 273 | 274 | public function getMaxRetention() { 275 | return $this->MaxRetention; 276 | } 277 | 278 | public function getMaxAPICallsLog() { 279 | return $this->MaxAPICallsLog; 280 | } 281 | 282 | public function getMaxAPICallsGet() { 283 | return $this->MaxAPICallsGet; 284 | } 285 | 286 | public function getMaxAPIFails() { 287 | return $this->MaxAPIFails; 288 | } 289 | 290 | 291 | /** 292 | * Setters 293 | */ 294 | public function setAnonymousAccountID($accountID) { 295 | $this->AnonymousAccountID = $accountID; 296 | } 297 | 298 | public function setPro1AccountID($accountID) { 299 | $this->Pro1AccountID = $accountID; 300 | } 301 | 302 | public function setPro2AccountID($accountID) { 303 | $this->Pro2AccountID = $accountID; 304 | } 305 | 306 | public function setPaymentApiUrl($paymentApiUrl) { 307 | $this->PaymentApiUrl = $paymentApiUrl; 308 | } 309 | 310 | public function setPaymentApiKey($paymentApiKey) { 311 | $this->PaymentApiKey = $paymentApiKey; 312 | } 313 | 314 | public function setSitename($sitename) { 315 | $this->Sitename = $sitename; 316 | } 317 | 318 | public function setIncomingLogDomain($incomingLogDomain) { 319 | $this->IncomingLogDomain = $incomingLogDomain; 320 | } 321 | 322 | public function setEmail($email) { 323 | $this->Email = $email; 324 | } 325 | 326 | public function setCronEnabled($cronEnabled) { 327 | $this->CronEnabled = $cronEnabled; 328 | } 329 | 330 | public function setCronInsert($cronInsert) { 331 | $this->CronInsert = $cronInsert; 332 | } 333 | 334 | public function setTempDir($tempDir) { 335 | $this->TempDir = $tempDir; 336 | } 337 | 338 | public function setAdminUser($adminUser) { 339 | $this->AdminUser = $adminUser; 340 | } 341 | 342 | public function setAdminPassword($adminPassword, $hash=true) { 343 | $this->AdminPassword = $hash ? password_hash($adminPassword, PASSWORD_DEFAULT) : $adminPassword; 344 | } 345 | 346 | public function setDemoAdminToken($demoAdminToken) { 347 | $this->DemoAdminToken = $demoAdminToken; 348 | } 349 | 350 | public function setDemoViewURL($demoViewURL) { 351 | $this->DemoViewURL = $demoViewURL; 352 | } 353 | 354 | public function setDemoDashboardURL($demoDashboardURL) { 355 | $this->DemoDashboardURL = $demoDashboardURL; 356 | } 357 | 358 | public function setMaxRetention($maxRetention) { 359 | $this->MaxRetention = $maxRetention; 360 | } 361 | 362 | public function setMaxAPICallsLog($maxAPICallsLog) { 363 | $this->MaxAPICallsLog = $maxAPICallsLog; 364 | } 365 | 366 | public function setMaxAPICallsGet($maxAPICallsGet) { 367 | $this->MaxAPICallsGet = $maxAPICallsGet; 368 | } 369 | 370 | public function setMaxAPIFails($maxAPIFails) { 371 | $this->MaxAPIFails = $maxAPIFails; 372 | } 373 | 374 | 375 | /** 376 | * Fill from database 377 | */ 378 | public function setFromDB($data) { 379 | $this->ID = $data->ID ?? null; 380 | $this->CreationDate = $data->CreationDate ?? null; 381 | $this->ModifyDate = $data->ModifyDate ?? null; 382 | $this->AnonymousAccountID = $data->AnonymousAccountID ?? null; 383 | $this->Pro1AccountID = $data->Pro1AccountID ?? null; 384 | $this->Pro2AccountID = $data->Pro2AccountID ?? null; 385 | $this->PaymentApiUrl = $data->PaymentApiUrl ?? null; 386 | $this->PaymentApiKey = $data->PaymentApiKey ?? null; 387 | $this->Sitename = $data->Sitename ?? null; 388 | $this->IncomingLogDomain = $data->IncomingLogDomain ?? null; 389 | $this->Email = $data->Email ?? null; 390 | $this->CronEnabled = $data->CronEnabled ?? null; 391 | $this->CronInsert = $data->CronInsert ?? null; 392 | $this->TempDir = $data->TempDir ?? null; 393 | $this->AdminUser = $data->AdminUser ?? null; 394 | $this->AdminPassword = $data->AdminPassword ?? null; 395 | $this->DemoAdminToken = $data->DemoAdminToken ?? null; 396 | $this->DemoViewURL = $data->DemoViewURL ?? null; 397 | $this->DemoDashboardURL = $data->DemoDashboardURL ?? null; 398 | $this->MaxRetention = $data->MaxRetention ?? null; 399 | $this->MaxAPICallsLog = $data->MaxAPICallsLog ?? null; 400 | $this->MaxAPICallsGet = $data->MaxAPICallsGet ?? null; 401 | $this->MaxAPIFails = $data->MaxAPIFails ?? null; 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /txtlog/entity/txtlog.php: -------------------------------------------------------------------------------- 1 | ID; 93 | } 94 | 95 | public function getIDHex() { 96 | return strtolower(Common::intToHex($this->ID)); 97 | } 98 | 99 | public function getCreationDate() { 100 | return Common::parseUniqueID(Common::intToHex($this->ID))->date; 101 | } 102 | 103 | public function getModifyDate() { 104 | return $this->ModifyDate; 105 | } 106 | 107 | public function getAccountID() { 108 | return $this->AccountID; 109 | } 110 | 111 | public function getServerID() { 112 | return $this->ServerID; 113 | } 114 | 115 | public function getRetention() { 116 | return $this->Retention; 117 | } 118 | 119 | public function getName() { 120 | return $this->Name; 121 | } 122 | 123 | public function getIPAddress() { 124 | return $this->IPAddress; 125 | } 126 | 127 | public function getUserHash() { 128 | return strtolower($this->UserHash); 129 | } 130 | 131 | public function getPassword() { 132 | return $this->Password; 133 | } 134 | 135 | public function getTokens() { 136 | return unserialize($this->Tokens); 137 | } 138 | 139 | public function getTokensString() { 140 | return $this->Tokens; 141 | } 142 | 143 | 144 | /** 145 | * Setters 146 | */ 147 | public function setID($id) { 148 | $this->ID = $id; 149 | } 150 | 151 | public function setAccountID($accountID) { 152 | $this->AccountID = $accountID; 153 | } 154 | 155 | public function setServerID($serverID) { 156 | $this->ServerID = $serverID; 157 | } 158 | 159 | public function setRetention($retention) { 160 | $this->Retention = $retention; 161 | } 162 | 163 | public function setName($name) { 164 | $this->Name = $name; 165 | } 166 | 167 | public function setIPAddress($ipAddress) { 168 | $this->IPAddress = $ipAddress; 169 | } 170 | 171 | public function setUsername($username) { 172 | if(strlen($username) < 1) { 173 | $this->UserHash = ''; 174 | return; 175 | } 176 | 177 | // Check username validity 178 | if(!preg_match("/^[A-Za-z0-9 \.@_-]+$/", $username)) { 179 | throw new Exception('ERROR_INVALID_USERNAME'); 180 | } 181 | 182 | // The username itself is not stored, only a truncated hash of the username 183 | $this->UserHash = substr(hash('sha256', $username), 0, 16); 184 | } 185 | 186 | public function setPassword($password, $hash=true) { 187 | if(strlen($password) > 0) { 188 | $this->Password = $hash ? password_hash($password, PASSWORD_DEFAULT) : $password; 189 | } 190 | } 191 | 192 | public function setTokens($tokens) { 193 | // note: json_encode an enum results in the value (e.g. 1) instead of an enum object 194 | $this->Tokens = serialize($tokens); 195 | } 196 | 197 | 198 | /** 199 | * Fill from database 200 | */ 201 | public function setFromDB($data) { 202 | $this->ID = $data->ID ?? null; 203 | $this->ModifyDate = $data->ModifyDate ?? ''; 204 | $this->AccountID = $data->AccountID ?? ''; 205 | $this->ServerID = $data->ServerID ?? ''; 206 | $this->Retention = $data->Retention ?? ''; 207 | $this->Name = $data->Name ?? ''; 208 | $this->IPAddress = $data->IPAddress ?? ''; 209 | $this->UserHash = $data->UserHash ?? ''; 210 | $this->Password = $data->Password ?? ''; 211 | $this->Tokens = $data->Tokens ?? ''; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /txtlog/entity/txtlogrow.php: -------------------------------------------------------------------------------- 1 | TxtlogID; 60 | } 61 | 62 | public function getID() { 63 | return $this->ID; 64 | } 65 | 66 | public function getCreationDate() { 67 | return Common::parseUniqueID($this->ID)->date; 68 | } 69 | 70 | public function getTimestamp() { 71 | return $this->Timestamp ?? Common::parseUniqueID($this->ID)->timestamp; 72 | } 73 | 74 | public function getDate() { 75 | return $this->Date; 76 | } 77 | 78 | public function getSearchFields() { 79 | return $this->SearchFields; 80 | } 81 | 82 | public function getData() { 83 | return $this->Data; 84 | } 85 | 86 | 87 | /** 88 | * Setters 89 | */ 90 | public function setTxtlogID($txtlogID) { 91 | $this->TxtlogID = $txtlogID; 92 | } 93 | 94 | public function setID($id) { 95 | $this->ID = $id; 96 | } 97 | 98 | // To manipulate retention, use this function to overrule the timestamp which changes TxtlogRow.Date 99 | public function setTimestamp($timestamp) { 100 | $this->Timestamp = $timestamp; 101 | } 102 | 103 | public function setSearchFields($searchFields) { 104 | $this->SearchFields = $searchFields; 105 | } 106 | 107 | public function setData($data) { 108 | $this->Data = $data; 109 | } 110 | 111 | 112 | /** 113 | * Fill from database 114 | */ 115 | public function setFromDB($data) { 116 | $this->TxtlogID = $data->TxtlogID ?? null; 117 | $this->ID = $data->HexID ? $data->HexID : null; 118 | $this->Date = $data->Date ?? null; 119 | $this->SearchFields = $data->SearchFields ?? null; 120 | $this->Data = $data->Data ?? null; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /txtlog/includes/cache.php: -------------------------------------------------------------------------------- 1 | IPAddress = Common::getIP(); 48 | $error = true; 49 | $this->minute = substr(date('i'), 0, 1); 50 | 51 | $redisHost = Constants::getRedisHost(); 52 | $redisPort = Constants::getRedisPort(); 53 | 54 | if(strlen($redisHost) > 0) { 55 | try { 56 | $this->redis = new \Redis(); 57 | 58 | if(Common::isInt($redisPort) && Common::isValidDomain($redisHost)) { 59 | $this->redis->pconnect($redisHost, $redisPort); 60 | } elseif(substr($redisHost, 0, 1) == '/') { 61 | // Redis Host starts with a slash and is not an URL, assume it's a unix socket 62 | $this->redis->pconnect($redisHost); 63 | } else { 64 | throw new Exception('Invalid redis configuration'); 65 | } 66 | $this->setDefaultSerializer(); 67 | 68 | $error = false; 69 | } catch(\RedisException $e) { 70 | } 71 | } 72 | 73 | if($error) { 74 | if($exitOnError) { 75 | // 503 Service Unavailable 76 | $httpCode = 503; 77 | Common::setHttpResponseCode($httpCode); 78 | exit; 79 | } else { 80 | throw new Exception('Cannot connect to Redis'); 81 | } 82 | } 83 | } 84 | 85 | 86 | /** 87 | * Set the default serializer, needed for storing arrays in Redis 88 | * 89 | * @return void 90 | */ 91 | private function setDefaultSerializer() { 92 | // Serialize data when needed, see https://github.com/phpredis/phpredis 93 | $this->redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_PHP); 94 | } 95 | 96 | 97 | /** 98 | * Get an entry from the cache 99 | * 100 | * @param key to get 101 | * @return cached value 102 | */ 103 | public function get($key) { 104 | return $this->redis->get($key); 105 | } 106 | 107 | 108 | /** 109 | * Set an entry in the cache, overwrite if it already exists 110 | * 111 | * @param key to set 112 | * @param value the data to store in the cache 113 | * @param ttl optional time-to-live in seconds 114 | * @return void 115 | */ 116 | public function set($key, $value, $ttl=null) { 117 | if(is_null($ttl)) { 118 | $this->redis->set($key, $value); 119 | } else { 120 | $this->redis->set($key, $value, ['ex'=>$ttl]); 121 | } 122 | } 123 | 124 | 125 | /** 126 | * Set an entry in the cache and return false if the key already exists 127 | * 128 | * @param key to set 129 | * @param value the data to store in the cache 130 | * @param ttl time-to-live in seconds 131 | * @return bool true if successful 132 | */ 133 | public function setNx($key, $value, $ttl) { 134 | return $this->redis->set($key, $value, ['nx', 'ex'=>$ttl]); 135 | } 136 | 137 | 138 | /** 139 | * Delete an entry from the cache 140 | * 141 | * @param key to delete 142 | * @return number of keys deleted 143 | */ 144 | public function del($key) { 145 | return $this->redis->unlink($key); 146 | } 147 | 148 | 149 | /** 150 | * Add an item to a stream 151 | * 152 | * @param name of the stream 153 | * @param value 154 | * @return void 155 | */ 156 | public function streamAdd($name, $value) { 157 | if(!is_array($value)) { 158 | $value = [$value]; 159 | } 160 | 161 | // * to auto generate an ID 162 | $this->redis->xAdd($name, "*", $value); 163 | } 164 | 165 | 166 | /** 167 | * Get items from a stream 168 | * 169 | * @param name of the stream 170 | * @param id of the first message to get 171 | * @param count, optional defaults to 1000 records 172 | * @return array with stream data 173 | */ 174 | public function streamRead($name, $id, $count=1000) { 175 | return $this->redis->xRead([$name=>$id], $count); 176 | } 177 | 178 | 179 | /** 180 | * Delete items from a stream 181 | * 182 | * @param name of the stream 183 | * @param ids array with ids to delete 184 | * @return number of messages removed 185 | */ 186 | public function streamDel($name, $ids) { 187 | if(empty($ids)) { 188 | return 0; 189 | } 190 | 191 | return $this->redis->xDel($name, $ids); 192 | } 193 | 194 | 195 | /** 196 | * Add items to an unsorted set 197 | * 198 | * @param key string 199 | * @param value string (multiple vaules are allowed) 200 | * @return true if the element is added 201 | */ 202 | public function setAdd($key, ...$arr) { 203 | $result = $this->redis->sAdd($key, ...$arr); 204 | 205 | return $result; 206 | } 207 | 208 | 209 | /** 210 | * Add items to a sorted set 211 | * 212 | * @param key string 213 | * @param score string (multiple scores are allowed) 214 | * @param value string (multiple vaules are allowed) 215 | * @return true if the element is added 216 | */ 217 | public function zSetAdd($key, ...$arr) { 218 | // Disable serializer to store raw values instead of serialized values so sorting works 219 | $this->redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_NONE); 220 | 221 | $result = $this->redis->zAdd($key, ...$arr); 222 | 223 | // Restore default serializer 224 | $this->setDefaultSerializer(); 225 | 226 | return $result; 227 | } 228 | 229 | 230 | /** 231 | * Get Geo IP information from cached IP information 232 | * 233 | * @param ip optional 234 | * @return object with IP and geo IP information 235 | */ 236 | public function getGeoIP($ip=null) { 237 | $ip = $ip ?? Common::getIP(); 238 | 239 | $result = (object)[ 240 | 'ip'=>$ip, 241 | 'cc'=>'', 242 | 'country'=>'', 243 | 'provider'=>'', 244 | 'tor'=>false 245 | ]; 246 | if(!Common::isIP($ip)) { 247 | return $result; 248 | } 249 | 250 | // Loopback exception, not in the ASN list 251 | if(substr($ip, 0, 4) == '127.' || $ip == '::1') { 252 | $result->provider = 'localhost'; 253 | } else { 254 | $ipInt = str_pad((string) gmp_import(inet_pton($ip)), 39, '0', STR_PAD_LEFT); 255 | 256 | $geoIP = explode(':', $this->redis->zRangeByLex('ip', "[$ipInt", '+', 0, 1)[0] ?? ''); 257 | 258 | $result->cc = $geoIP[1] ?? ''; 259 | $result->country = Country::getCountry($geoIP[1] ?? ''); 260 | $result->provider = $geoIP[2] ?? ''; 261 | $result->tor = $this->redis->sIsMember('tor', $ip); 262 | } 263 | 264 | return $result; 265 | } 266 | 267 | 268 | /** 269 | * Parse the user agent HTTP header in a readable object 270 | * PHP get_browser is still rather slow, so use a cache if possible 271 | * 272 | * @param optional user agent 273 | * @return object 274 | */ 275 | public function parseUserAgent($ua='') { 276 | $ua = $ua ?? $_SERVER['HTTP_USER_AGENT'] ?? ''; 277 | if(empty($ua)) { 278 | return (object)[]; 279 | } 280 | $cacheName = 'browser_'.substr(hash('sha256', $ua), 0, 32); 281 | $cacheValue = $this->get($cacheName); 282 | 283 | if($cacheValue) { 284 | return $cacheValue; 285 | } 286 | 287 | $browserInfo = get_browser($ua); 288 | $browser = $browserInfo->browser; 289 | $version = $browserInfo->version; 290 | // Parse user agents like curl/7.88.1 291 | if(substr($ua, 0, 5) == 'curl/') { 292 | $version = substr($ua, 5); 293 | } 294 | $os = $browserInfo->platform; 295 | 296 | $cacheValue = (object)[ 297 | 'ua'=>$ua, 298 | 'name'=>$browser, 299 | 'version'=>$version, 300 | 'os'=>$os, 301 | 'get_browser'=>$browserInfo 302 | ]; 303 | 304 | $this->set($cacheName, $cacheValue, 240); 305 | 306 | return $cacheValue; 307 | } 308 | 309 | 310 | /** 311 | * Add generic IP usage 312 | * 313 | * @param accountID 314 | * @param incr optional to increase with more than 1 315 | * @throws Exception error message when the API limit has been reached 316 | * @return void 317 | */ 318 | public function addIPUsage($accountID, $increase=1) { 319 | if($increase < 1) { 320 | return; 321 | } 322 | 323 | $name = "apiusage.{$this->minute}.{$this->IPAddress}"; 324 | 325 | if($this->get($name) >= (new Account)->get($accountID)->getMaxIPUsage()) { 326 | throw new Exception('ERROR_TOO_MANY_REQUESTS'); 327 | } 328 | 329 | // Use 'NX' modifier on expire when php-redis is updated on Ubuntu? 330 | $this->redis->multi() 331 | ->incr($name, $increase) 332 | ->expire($name, 600) 333 | ->exec(); 334 | } 335 | 336 | /** 337 | * Update API usage for creating, updating or deleting log metadata 338 | * 339 | * @throws Exception error message when the API limit has been reached 340 | * @return void 341 | */ 342 | public function addIPUsageLog() { 343 | $name = "apiusage.log.{$this->minute}.{$this->IPAddress}"; 344 | 345 | if($this->get($name) >= (new Settings)->get()->getMaxAPICallsLog()) { 346 | throw new Exception('ERROR_TOO_MANY_REQUESTS'); 347 | } 348 | 349 | $this->redis->multi() 350 | ->incr($name, 1) 351 | ->expire($name, 600) 352 | ->exec(); 353 | } 354 | 355 | 356 | /** 357 | * Update API usage for getting log data 358 | * 359 | * @param increase optional to add this number of api usages (default 1) 360 | * @throws Exception error message when the API limit has been reached 361 | * @return void 362 | */ 363 | public function addIPUsageGet($increase=1) { 364 | $name = "apiusage.get.{$this->minute}.{$this->IPAddress}"; 365 | 366 | if($this->get($name) >= (new Settings)->get()->getMaxAPICallsGet()) { 367 | throw new Exception('ERROR_TOO_MANY_REQUESTS'); 368 | } 369 | 370 | $this->redis->multi() 371 | ->incr($name, $increase) 372 | ->expire($name, 600) 373 | ->exec(); 374 | } 375 | 376 | 377 | /** 378 | * Log one failed API call for the client IP 379 | * 380 | * @return void 381 | */ 382 | public function addIPFail() { 383 | $name = "apifail.{$this->minute}.{$this->IPAddress}"; 384 | 385 | $this->redis->multi() 386 | ->incr($name, 1) 387 | ->expire($name, 600) 388 | ->exec(); 389 | } 390 | 391 | 392 | /** 393 | * Check if the current client IP can do another request 394 | * 395 | * @throws Exception error message when the API fail limit has been reached 396 | * @return void 397 | */ 398 | public function verifyIPFails() { 399 | if($this->get("apifail.{$this->minute}.{$this->IPAddress}") >= (new Settings)->get()->getMaxAPIFails()) { 400 | throw new Exception('ERROR_TOO_MANY_REQUESTS'); 401 | } 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /txtlog/includes/country.php: -------------------------------------------------------------------------------- 1 | 'United Arab Emirates', 8 | 'AF'=>'Afghanistan', 9 | 'AG'=>'Antigua and Barbuda', 10 | 'AI'=>'Anguilla', 11 | 'AL'=>'Albania', 12 | 'AM'=>'Armenia', 13 | 'AO'=>'Angola', 14 | 'AQ'=>'Antarctica', 15 | 'AR'=>'Argentina', 16 | 'AS'=>'American Samoa', 17 | 'AT'=>'Austria', 18 | 'AU'=>'Australia', 19 | 'AW'=>'Aruba', 20 | 'AX'=>'Åland Islands', 21 | 'AZ'=>'Azerbaijan', 22 | 'BA'=>'Bosnia and Herzegovina', 23 | 'BB'=>'Barbados', 24 | 'BD'=>'Bangladesh', 25 | 'BE'=>'Belgium', 26 | 'BF'=>'Burkina Faso', 27 | 'BG'=>'Bulgaria', 28 | 'BH'=>'Bahrain', 29 | 'BI'=>'Burundi', 30 | 'BJ'=>'Benin', 31 | 'BL'=>'Saint Barthélemy', 32 | 'BM'=>'Bermuda', 33 | 'BN'=>'Brunei Darussalam', 34 | 'BO'=>'Bolivia, Plurinational State of', 35 | 'BQ'=>'Bonaire, Sint Eustatius and Saba', 36 | 'BR'=>'Brazil', 37 | 'BS'=>'Bahamas', 38 | 'BT'=>'Bhutan', 39 | 'BV'=>'Bouvet Island', 40 | 'BW'=>'Botswana', 41 | 'BY'=>'Belarus', 42 | 'BZ'=>'Belize', 43 | 'CA'=>'Canada', 44 | 'CC'=>'Cocos (Keeling) Islands', 45 | 'CD'=>'Congo, Democratic Republic of the', 46 | 'CF'=>'Central African Republic', 47 | 'CG'=>'Congo', 48 | 'CH'=>'Switzerland', 49 | 'CI'=>'Côte d\'Ivoire', 50 | 'CK'=>'Cook Islands', 51 | 'CL'=>'Chile', 52 | 'CM'=>'Cameroon', 53 | 'CN'=>'China', 54 | 'CO'=>'Colombia', 55 | 'CR'=>'Costa Rica', 56 | 'CU'=>'Cuba', 57 | 'CV'=>'Cabo Verde', 58 | 'CW'=>'Curaçao', 59 | 'CX'=>'Christmas Island', 60 | 'CY'=>'Cyprus', 61 | 'CZ'=>'Czechia', 62 | 'DE'=>'Germany', 63 | 'DJ'=>'Djibouti', 64 | 'DK'=>'Denmark', 65 | 'DM'=>'Dominica', 66 | 'DO'=>'Dominican Republic', 67 | 'DZ'=>'Algeria', 68 | 'EC'=>'Ecuador', 69 | 'EE'=>'Estonia', 70 | 'EG'=>'Egypt', 71 | 'EH'=>'Western Sahara', 72 | 'ER'=>'Eritrea', 73 | 'ES'=>'Spain', 74 | 'ET'=>'Ethiopia', 75 | 'FI'=>'Finland', 76 | 'FJ'=>'Fiji', 77 | 'FK'=>'Falkland Islands (Malvinas)', 78 | 'FM'=>'Micronesia, Federated States of', 79 | 'FO'=>'Faroe Islands', 80 | 'FR'=>'France', 81 | 'GA'=>'Gabon', 82 | 'GB'=>'United Kingdom of Great Britain and Northern Ireland', 83 | 'GD'=>'Grenada', 84 | 'GE'=>'Georgia', 85 | 'GF'=>'French Guiana', 86 | 'GG'=>'Guernsey', 87 | 'GH'=>'Ghana', 88 | 'GI'=>'Gibraltar', 89 | 'GL'=>'Greenland', 90 | 'GM'=>'Gambia', 91 | 'GN'=>'Guinea', 92 | 'GP'=>'Guadeloupe', 93 | 'GQ'=>'Equatorial Guinea', 94 | 'GR'=>'Greece', 95 | 'GS'=>'South Georgia and the South Sandwich Islands', 96 | 'GT'=>'Guatemala', 97 | 'GU'=>'Guam', 98 | 'GW'=>'Guinea-Bissau', 99 | 'GY'=>'Guyana', 100 | 'HK'=>'Hong Kong', 101 | 'HM'=>'Heard Island and McDonald Islands', 102 | 'HN'=>'Honduras', 103 | 'HR'=>'Croatia', 104 | 'HT'=>'Haiti', 105 | 'HU'=>'Hungary', 106 | 'ID'=>'Indonesia', 107 | 'IE'=>'Ireland', 108 | 'IL'=>'Israel', 109 | 'IM'=>'Isle of Man', 110 | 'IN'=>'India', 111 | 'IO'=>'British Indian Ocean Territory', 112 | 'IQ'=>'Iraq', 113 | 'IR'=>'Iran, Islamic Republic of', 114 | 'IS'=>'Iceland', 115 | 'IT'=>'Italy', 116 | 'JE'=>'Jersey', 117 | 'JM'=>'Jamaica', 118 | 'JO'=>'Jordan', 119 | 'JP'=>'Japan', 120 | 'KE'=>'Kenya', 121 | 'KG'=>'Kyrgyzstan', 122 | 'KH'=>'Cambodia', 123 | 'KI'=>'Kiribati', 124 | 'KM'=>'Comoros', 125 | 'KN'=>'Saint Kitts and Nevis', 126 | 'KP'=>'Korea, Democratic People\'s Republic of', 127 | 'KR'=>'Korea, Republic of', 128 | 'KW'=>'Kuwait', 129 | 'KY'=>'Cayman Islands', 130 | 'KZ'=>'Kazakhstan', 131 | 'LA'=>'Lao People\'s Democratic Republic', 132 | 'LB'=>'Lebanon', 133 | 'LC'=>'Saint Lucia', 134 | 'LI'=>'Liechtenstein', 135 | 'LK'=>'Sri Lanka', 136 | 'LR'=>'Liberia', 137 | 'LS'=>'Lesotho', 138 | 'LT'=>'Lithuania', 139 | 'LU'=>'Luxembourg', 140 | 'LV'=>'Latvia', 141 | 'LY'=>'Libya', 142 | 'MA'=>'Morocco', 143 | 'MC'=>'Monaco', 144 | 'MD'=>'Moldova, Republic of', 145 | 'ME'=>'Montenegro', 146 | 'MF'=>'Saint Martin (French part)', 147 | 'MG'=>'Madagascar', 148 | 'MH'=>'Marshall Islands', 149 | 'MK'=>'North Macedonia', 150 | 'ML'=>'Mali', 151 | 'MM'=>'Myanmar', 152 | 'MN'=>'Mongolia', 153 | 'MO'=>'Macao', 154 | 'MP'=>'Northern Mariana Islands', 155 | 'MQ'=>'Martinique', 156 | 'MR'=>'Mauritania', 157 | 'MS'=>'Montserrat', 158 | 'MT'=>'Malta', 159 | 'MU'=>'Mauritius', 160 | 'MV'=>'Maldives', 161 | 'MW'=>'Malawi', 162 | 'MX'=>'Mexico', 163 | 'MY'=>'Malaysia', 164 | 'MZ'=>'Mozambique', 165 | 'NA'=>'Namibia', 166 | 'NC'=>'New Caledonia', 167 | 'NE'=>'Niger', 168 | 'NF'=>'Norfolk Island', 169 | 'NG'=>'Nigeria', 170 | 'NI'=>'Nicaragua', 171 | 'NL'=>'Netherlands, Kingdom of the', 172 | 'NO'=>'Norway', 173 | 'NP'=>'Nepal', 174 | 'NR'=>'Nauru', 175 | 'NU'=>'Niue', 176 | 'NZ'=>'New Zealand', 177 | 'OM'=>'Oman', 178 | 'PA'=>'Panama', 179 | 'PE'=>'Peru', 180 | 'PF'=>'French Polynesia', 181 | 'PG'=>'Papua New Guinea', 182 | 'PH'=>'Philippines', 183 | 'PK'=>'Pakistan', 184 | 'PL'=>'Poland', 185 | 'PM'=>'Saint Pierre and Miquelon', 186 | 'PN'=>'Pitcairn', 187 | 'PR'=>'Puerto Rico', 188 | 'PS'=>'Palestine, State of', 189 | 'PT'=>'Portugal', 190 | 'PW'=>'Palau', 191 | 'PY'=>'Paraguay', 192 | 'QA'=>'Qatar', 193 | 'RE'=>'Réunion', 194 | 'RO'=>'Romania', 195 | 'RS'=>'Serbia', 196 | 'RU'=>'Russian Federation', 197 | 'RW'=>'Rwanda', 198 | 'SA'=>'Saudi Arabia', 199 | 'SB'=>'Solomon Islands', 200 | 'SC'=>'Seychelles', 201 | 'SD'=>'Sudan', 202 | 'SE'=>'Sweden', 203 | 'SG'=>'Singapore', 204 | 'SH'=>'Saint Helena, Ascension and Tristan da Cunha', 205 | 'SI'=>'Slovenia', 206 | 'SJ'=>'Svalbard and Jan Mayen', 207 | 'SK'=>'Slovakia', 208 | 'SL'=>'Sierra Leone', 209 | 'SM'=>'San Marino', 210 | 'SN'=>'Senegal', 211 | 'SO'=>'Somalia', 212 | 'SR'=>'Suriname', 213 | 'SS'=>'South Sudan', 214 | 'ST'=>'Sao Tome and Principe', 215 | 'SV'=>'El Salvador', 216 | 'SX'=>'Sint Maarten (Dutch part)', 217 | 'SY'=>'Syrian Arab Republic', 218 | 'SZ'=>'Eswatini', 219 | 'TC'=>'Turks and Caicos Islands', 220 | 'TD'=>'Chad', 221 | 'TF'=>'French Southern Territories', 222 | 'TG'=>'Togo', 223 | 'TH'=>'Thailand', 224 | 'TJ'=>'Tajikistan', 225 | 'TK'=>'Tokelau', 226 | 'TL'=>'Timor-Leste', 227 | 'TM'=>'Turkmenistan', 228 | 'TN'=>'Tunisia', 229 | 'TO'=>'Tonga', 230 | 'TR'=>'Türkiye', 231 | 'TT'=>'Trinidad and Tobago', 232 | 'TV'=>'Tuvalu', 233 | 'TW'=>'Taiwan, Province of China', 234 | 'TZ'=>'Tanzania, United Republic of', 235 | 'UA'=>'Ukraine', 236 | 'UG'=>'Uganda', 237 | 'UM'=>'United States Minor Outlying Islands', 238 | 'US'=>'United States of America', 239 | 'UY'=>'Uruguay', 240 | 'UZ'=>'Uzbekistan', 241 | 'VA'=>'Holy See', 242 | 'VC'=>'Saint Vincent and the Grenadines', 243 | 'VE'=>'Venezuela, Bolivarian Republic of', 244 | 'VG'=>'Virgin Islands (British)', 245 | 'VI'=>'Virgin Islands (U.S.)', 246 | 'VN'=>'Viet Nam', 247 | 'VU'=>'Vanuatu', 248 | 'WF'=>'Wallis and Futuna', 249 | 'WS'=>'Samoa', 250 | 'YE'=>'Yemen', 251 | 'YT'=>'Mayotte', 252 | 'ZA'=>'South Africa', 253 | 'ZM'=>'Zambia', 254 | 'ZW'=>'Zimbabwe' 255 | ]; 256 | 257 | 258 | /** 259 | * Translate a two letter country code into a country name 260 | * 261 | * @param two letter country code 262 | * @return string with the country name 263 | */ 264 | public static function getCountry($code) { 265 | return static::$countries[$code] ?? ''; 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /txtlog/includes/cron.php: -------------------------------------------------------------------------------- 1 | get(useCache: false)->getCronEnabled()) { 34 | echo "Cron job disabled in settings, exiting\n"; 35 | exit; 36 | } 37 | } 38 | 39 | 40 | /** 41 | * Update the locally cached settings, accounts, etc. 42 | * 43 | * @return void 44 | */ 45 | public function updateLocalCache() { 46 | $result = (object)['debug'=>'']; 47 | $time = microtime(true); 48 | 49 | $result->debug .= "Refreshing settings, accounts and servers in the cache..."; 50 | 51 | try { 52 | // Open connection to the database first so a connection error can be caught instead of the Settings controller exiting the script 53 | (new Settings)->open(exitOnError: false); 54 | (new Settings)->get(useCache: false); 55 | (new Account)->getAll(useCache: false); 56 | (new Server)->getAll(useCache: false); 57 | } catch(ConnectionException $eConn) { 58 | $result->debug .= "\n* ERROR: Database connection fail ({$eConn->getMessage()}), aborting\n"; 59 | return $result; 60 | } 61 | 62 | $result->debug .= "complete in ".round(microtime(true) - $time, 4)." seconds\n"; 63 | 64 | return $result; 65 | } 66 | 67 | 68 | /** 69 | * Get data from the cache and store it on the local filesystem 70 | * 71 | * @return array with debug information 72 | */ 73 | public function cacheToFile() { 74 | $time = microtime(true); 75 | $cache = new Cache(); 76 | $settings = (new Settings)->get(); 77 | 78 | $filename = $settings->getTempDir().'/txtlog.rows.'.((new \DateTimeImmutable())->format('Y-m-d_Hisv_')).Common::getRandomString(10); 79 | $id = 0; 80 | $insertRows = [[]]; 81 | // Insert this many rows on the database in one statement 82 | $limit = $settings->getCronInsert(); 83 | $rowTemps = []; 84 | $result = (object)[ 85 | 'debug'=>'', 86 | 'inserts'=>0 87 | ]; 88 | $stream = 'txtlogrows'; 89 | $streamDel = []; 90 | 91 | $cacheRows = $cache->streamRead($stream, $id, $limit); 92 | if(!isset($cacheRows['txtlogrows'])) { 93 | $result->debug .= "nothing\n"; 94 | return $result; 95 | } 96 | $result->inserts = count($cacheRows['txtlogrows']); 97 | 98 | foreach($cacheRows[$stream] as $key=>$rowTemp) { 99 | $rowTemps[] = (object)$rowTemp; 100 | $streamDel[] = $key; 101 | 102 | $serverID = $rowTemp['server']; 103 | $txtlogRowData = unserialize(gzuncompress($rowTemp['row'])); 104 | $insertRows[$serverID][] = $txtlogRowData; 105 | } 106 | 107 | // Remove first empty array item 108 | unset($insertRows[0]); 109 | 110 | // Store the rows in a local file 111 | if(count($streamDel) > 0) { 112 | // Set a lock to prevent fileToDatabase from processing this file until write is complete 113 | $rnd = Common::getRandomString(16); 114 | $ttl = 120; 115 | $cache->setNx($filename, $rnd, $ttl); 116 | 117 | file_put_contents($filename, serialize($insertRows)); 118 | 119 | // Free the lock 120 | $cache->del($filename); 121 | } 122 | 123 | // Remove entries from cache 124 | $cache->streamDel($stream, $streamDel); 125 | 126 | $result->debug .= "stored {$result->inserts} rows in $filename (".round(microtime(true) - $time, 4)." seconds).\n"; 127 | 128 | return $result; 129 | } 130 | 131 | 132 | /** 133 | * Get rows from the filesystem and store in the database 134 | * Processes max one file 135 | * 136 | * @return array with debug information and the amount of rows inserted 137 | */ 138 | public function fileToDatabase() { 139 | $cache = new Cache(); 140 | $txtlogRow = new TxtlogRow(); 141 | $ttl = 120; 142 | 143 | $filename = (new Settings)->get()->getTempDir().'/txtlog.rows.*'; 144 | $files = glob($filename); 145 | $result = (object)[ 146 | 'debug'=>'', 147 | 'inserts'=>0 148 | ]; 149 | 150 | if(empty($files)) { 151 | $result->debug .= "nothing\n"; 152 | return $result; 153 | } 154 | 155 | foreach($files as $file) { 156 | // Set an exclusive lock so multiples jobs can process different files 157 | $id = Common::getRandomString(16); 158 | if(!$cache->setNx($file, $id, $ttl)) { 159 | $result->debug .= "(skipping locked $file) "; 160 | continue; 161 | } 162 | 163 | if(!is_file($file)) { 164 | // In chronological order: job 1 processed this file, job 2 found this file with "glob", job 1 removed this file and its lock, job 2 sets a lock and tries to open this file 165 | $result->debug .= "\n* ERROR: file $file does not exist anymore\n"; 166 | return $result; 167 | } 168 | 169 | $data = file_get_contents($file); 170 | if(Common::isSerialized($data)) { 171 | $data = unserialize($data); 172 | } else { 173 | $newfile = str_replace('/txtlog.rows.', '/corrupt.txtlog.rows.', $file); 174 | $result->debug .= "\n* ERROR: corrupt data, "; 175 | $result->debug .= rename($file, $newfile) ? "renamed $file to $newfile\n" : "ERROR: cannot rename $file to $newfile\n"; 176 | return $result; 177 | } 178 | $result->debug .= "parsing file $file\n"; 179 | 180 | try { 181 | foreach($data as $serverID=>$insertRowServer) { 182 | $rowCount = count($insertRowServer); 183 | $result->debug .= "* Inserting $rowCount row(s) on database server $serverID..."; 184 | 185 | try { 186 | $startInsert = microtime(true); 187 | 188 | $txtlogRow->setServer($serverID); 189 | // Insert in one batch, this is a lot faster compared to single row inserts over a remote connection (a transaction does not help here) 190 | $txtlogRow->insertMultiple($insertRowServer); 191 | 192 | $size = round(strlen(serialize($insertRowServer)) / 1024, 2); 193 | $result->debug .= round(microtime(true) - $startInsert, 4)." seconds (approx. $size KB).\n"; 194 | $result->inserts += $rowCount; 195 | } catch(ConnectionException $eConn) { 196 | if($cache->get($file) == $id) { 197 | $cache->del($file); 198 | } 199 | $result->debug .= "\n* ERROR: Database connection fail ({$eConn->getMessage()}), aborting\n"; 200 | return $result; 201 | } catch(Exception $eInsert) { 202 | $result->debug .= "\n* ERROR: Insert rows exception: {$eInsert->getMessage()}\n"; 203 | $newfile = str_replace('/txtlog.rows.', '/exception.txtlog.rows.', $file); 204 | $result->debug .= rename($file, $newfile) ? "renamed $file to $newfile\n" : "ERROR: cannot rename $file to $newfile\n"; 205 | return $result; 206 | } 207 | } 208 | 209 | $result->debug .= unlink($file) ? "* Removed $file\n" : "ERROR: cannot delete $file\n"; 210 | } catch(Exception $e) { 211 | $result->debug .= "ERROR : {$e->getMessage()}\n"; 212 | } 213 | 214 | // Free the lock 215 | if($cache->get($file) == $id) { 216 | $cache->del($file); 217 | } 218 | 219 | // Process max one file per function call 220 | break; 221 | } 222 | 223 | return $result; 224 | } 225 | 226 | 227 | /** 228 | * Download a file and store it in the tmp directory 229 | * 230 | * @param url the (https) link to download 231 | * @param filename (basename) of the file, e.g. 'ip.gz' 232 | * @param minSize optional make sure the file is at least this many bytes 233 | * @return debug text 234 | */ 235 | private function downloadToTmp($url, $filename, $minSize=2000) { 236 | $result = ''; 237 | 238 | // Store in the tmp directory 239 | $filename = (new Settings)->get()->getTempDir()."/$filename"; 240 | 241 | $ch = curl_init($url); 242 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 243 | $data = curl_exec($ch); 244 | $size = strlen($data); 245 | $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); 246 | curl_close($ch); 247 | if($data === false || $status != 200 || $size < $minSize) { 248 | return "Curl exception (1), status=$status, size=$size\nCannot download database from $url"; 249 | } 250 | file_put_contents($filename, $data); 251 | $result .= "Download $size bytes from $url and store in $filename"; 252 | 253 | return $result; 254 | } 255 | 256 | 257 | /** 258 | * Download IP address country files released in the public domain: https://iptoasn.com/ 259 | * 260 | * @return debug text 261 | */ 262 | public function geoIPDownload() { 263 | $url = 'https://iptoasn.com/data/ip2asn-combined.tsv.gz'; 264 | $filename = 'ip.gz'; 265 | 266 | return $this->downloadToTmp($url, $filename); 267 | } 268 | 269 | 270 | /** 271 | * Download IP addresses of Tor exit nodes 272 | * 273 | * @return debug text 274 | */ 275 | public function torIPDownload() { 276 | // https://blog.torproject.org/changes-tor-exit-list-service/ 277 | $url = 'https://check.torproject.org/torbulkexitlist'; 278 | $filename = 'tor'; 279 | 280 | return $this->downloadToTmp($url, $filename); 281 | } 282 | 283 | 284 | /** 285 | * Parse a geo IP file and store it in memory 286 | * These files are small enough to store in memory instead of a database 287 | * 288 | * @return debug text 289 | */ 290 | public function geoIPParse() { 291 | $cache = new Cache(); 292 | $result = ''; 293 | $set = 'ip'; 294 | $setData = []; 295 | $ipFile = (new Settings)->get()->getTempDir().'/ip.gz'; 296 | 297 | if(!is_file($ipFile)) { 298 | return "File not found: $ipFile"; 299 | } 300 | 301 | $handle = gzopen($ipFile, 'r'); 302 | while(!gzeof($handle)) { 303 | $line = gzgets($handle, 4096); 304 | $arr = explode("\t", $line); 305 | $start = $arr[0] ?? ''; 306 | $startNum = str_pad((string) gmp_import(inet_pton($start)), 39, '0', STR_PAD_LEFT); 307 | $end = $arr[1] ?? ''; 308 | $endNum = str_pad((string) gmp_import(inet_pton($end)), 39, '0', STR_PAD_LEFT); 309 | $country = $arr[3] ?? ''; 310 | $provider = trim($arr[4] ?? ''); 311 | 312 | // Skip unknown countries 313 | if($country == 'None' || $country == '' || $start == '' || $end == '') { 314 | continue; 315 | } 316 | 317 | $setData[] = 0; // score 318 | $setData[] = "$endNum:$country:$provider"; 319 | } 320 | gzclose($handle); 321 | 322 | // Clear cached IP set and add new one 323 | $cache->del($set); 324 | $stored = $cache->zSetAdd($set, ...$setData); 325 | $result .= "Stored $stored items in cacheset \"$set\""; 326 | 327 | return $result; 328 | } 329 | 330 | 331 | /** 332 | * Parse a tor file with exit IP addresses and store it in memory 333 | * 334 | * @return debug text 335 | */ 336 | public function torIPParse() { 337 | $cache = new Cache(); 338 | $result = ''; 339 | $set = 'tor'; 340 | $setData = []; 341 | $torFile = (new Settings)->get()->getTempDir().'/tor'; 342 | 343 | if(!is_file($torFile)) { 344 | return "File not found: $torFile"; 345 | } 346 | 347 | $handle = gzopen($torFile, 'r'); 348 | while(!gzeof($handle)) { 349 | $ip = trim(gzgets($handle, 4096)); 350 | if(Common::isIP($ip)) { 351 | $setData[] = $ip; 352 | } 353 | } 354 | gzclose($handle); 355 | 356 | // Clear cached IP set and add new one 357 | $cache->del($set); 358 | $stored = $cache->setAdd($set, ...$setData); 359 | $result .= "Stored $stored items in cacheset \"$set\""; 360 | 361 | return $result; 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /txtlog/includes/dashboard.php: -------------------------------------------------------------------------------- 1 | log->name ?? str_replace('_', ' ', $output->error ?? '')) ?: 'Txtlog dashboard'; 16 | $create = isset($output->log->create) ? "Created: {$output->log->create}
" : ''; 17 | $authorization = $output->log->authorization ?? '' == 'yes' ? 'Password protected: '.$output->log->authorization.'
' : ''; 18 | $dateError = isset($output->log->date_error) ? ' is-danger' : ''; 19 | $rowText = isset($output->error) ? '' : 'Logs: '.Common::formatReadable($output->log->total_rows ?? 0).'
'; 20 | $warning = $output->log->warning ?? ''; 21 | $resetUrl = $output->log->base_url ?? ''; 22 | 23 | $date = Common::get('date', 0, true); 24 | $data = Common::get('data', 0, true); 25 | 26 | $nav = ''; 27 | if(!empty($output->rows)) { 28 | $nav = << 30 |
31 |  <  32 |
33 |
34 |
35 |
36 |  >  37 |
38 | 39 | END; 40 | } 41 | 42 | $html = << 44 |
45 |

46 | $name 47 |

48 |

49 | $rowText 50 | $create 51 | $authorization 52 |

53 |
54 | 55 |
56 |
57 | END; 58 | 59 | if(!isset($output->error)) { 60 | $html .= << 62 |
63 | 64 |
65 |
66 | 67 |
68 |
69 |  Reset   70 |
71 |
72 |
73 |
74 | 75 |
76 |
77 | 78 |
79 |
80 | 81 |
82 |
83 | $nav 84 | END; 85 | } 86 | 87 | $i = 0; 88 | foreach($output->rows ?? [] as $row) { 89 | // Add stripes to even/uneven rows 90 | $i++; 91 | $class = $i % 2 == 0 ? ' striped' : ''; 92 | 93 | $date = $row->date; 94 | $date = Common::getString($date); 95 | $date = str_replace(' ', '
', $date); 96 | 97 | $logdata = $row->log; 98 | // Non-json log 99 | if(isset($logdata->data) && !Common::isJson($logdata->data)) { 100 | $logdata = $logdata->data; 101 | } else { 102 | $logdata = json_encode($logdata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 103 | } 104 | $logdata = Common::getString($logdata); 105 | 106 | $html .= << 108 |
109 | $date 110 |
111 |
$logdata
112 | 113 | 114 | END; 115 | } 116 | 117 | $html .= <<$nav 119 |

120 | $warning 121 |

122 | 123 | 124 | 125 | END; 126 | 127 | return $html; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /txtlog/includes/error.php: -------------------------------------------------------------------------------- 1 | 403, 19 | 20 | // 400 Bad Request 21 | 'ERROR_INVALID_ACTION'=>400, 22 | 23 | // 400 Bad Request 24 | 'ERROR_INVALID_JSON'=>400, 25 | 26 | // 400 Bad Request 27 | 'ERROR_INVALID_USERNAME'=>400, 28 | 29 | // 405 Method Not Allowed 30 | 'ERROR_METHOD_NOT_ALLOWED'=>405, 31 | 32 | // 500 Internal Server Error 33 | 'ERROR_NO_SERVER_AVAILABLE'=>500, 34 | 35 | // 400 Bad Request 36 | 'ERROR_NO_VALID_ROWS'=>400, 37 | 38 | // 404 Not Found 39 | 'ERROR_NOT_FOUND'=>404, 40 | 41 | // 400 Bad Request 42 | 'ERROR_PROVIDE_SCOPE_ADMIN_INSERT_VIEW'=>400, 43 | 44 | // 503 Service Unavailable 45 | 'ERROR_SERVICE_UNAVAILABLE'=>503, 46 | 47 | // 429 Too Many Requests 48 | 'ERROR_TOO_MANY_REQUESTS'=>429, 49 | 50 | // 400 Bad Request 51 | 'ERROR_TOKEN_LIMIT_REACHED'=>400, 52 | 53 | // 500 Internal Server Error 54 | 'ERROR_UNKNOWN'=>500, 55 | 56 | // 400 Bad Request 57 | 'ERROR_USERNAME_ALREADY_EXISTS'=>400, 58 | ]; 59 | 60 | 61 | /** 62 | * Default HTTP status code to return if the error message is not found 63 | * 64 | * @var int 65 | */ 66 | private static $defaultHttpResponse = 200; 67 | 68 | 69 | /** 70 | * Get the accompanying http response code belonging to an error message 71 | * 72 | * @param message the error message 73 | * @return int 74 | */ 75 | public static function getHttpCode($message) { 76 | return array_key_exists($message, self::$errorList) ? self::$errorList[$message] : self::$defaultHttpResponse; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /txtlog/includes/priv.php: -------------------------------------------------------------------------------- 1 | $priv, 71 | 'token'=>Common::base58Encode($priv->value.Common::getRandomHex(self::$randomBits / 4).$txtlog->getIDHex()) 72 | ]; 73 | 74 | // Public dashboard, store GET parameters as well 75 | if($priv == Priv::View) { 76 | $get = Common::get('data', self::$maxSearchLength); 77 | if(strlen($get) > 1) { 78 | $payload->get = json_encode($get); 79 | $payload->getUrl = http_build_query(['data'=>$get]); 80 | } 81 | } 82 | 83 | return $payload; 84 | } 85 | 86 | 87 | /** 88 | * Parse a token 89 | * 90 | * @param token string 91 | * @throws Exception error message 92 | * @return token object 93 | */ 94 | public static function parseToken($token) { 95 | if(strlen($token) > 100 || !Common::isBase58($token)) { 96 | throw new Exception('ERROR_FORBIDDEN'); 97 | } 98 | 99 | $hexID = Common::base58Decode($token); 100 | $txtlogController = new TxtlogController(); 101 | $txtlogID = substr($hexID, -16); 102 | 103 | $txtlog = $txtlogController->getByHex($txtlogID); 104 | foreach($txtlog->getTokens() ?: [] as $validToken) { 105 | if($validToken->token == $token) { 106 | $validToken->txtlog = $txtlog; 107 | return $validToken; 108 | } 109 | } 110 | 111 | throw new Exception('ERROR_FORBIDDEN'); 112 | } 113 | 114 | 115 | /** 116 | * Add a new token and store it in the database 117 | * 118 | * @param txtlog object of the existing log 119 | * @param scope optional scope, if omitted defaults to the POST parameter "scope" 120 | * @throws Exception error message 121 | * @return token object 122 | */ 123 | public static function addToken($txtlog, $scope=null) { 124 | $txtlogController = new TxtlogController(); 125 | 126 | if(count($txtlog->getTokens()) >= self::$maxTokens) { 127 | throw new Exception('ERROR_TOKEN_LIMIT_REACHED'); 128 | } 129 | 130 | // Generate a new token 131 | $token = self::generateToken($txtlog); 132 | 133 | // Refresh because tokens are stored denormalized 134 | $txtlog = $txtlogController->getByID($txtlog->getID(), useCache: false); 135 | 136 | // Store the new token in the database 137 | $txtlog->setTokens(array_merge($txtlog->getTokens(), [$token])); 138 | $txtlogController->update($txtlog); 139 | 140 | return $token; 141 | } 142 | 143 | 144 | /** 145 | * Delete a given token 146 | * 147 | * @param txtlog object of the existing log 148 | * @param token string 149 | * @throws Exception error message 150 | * @return array 151 | */ 152 | public static function deleteToken($txtlog, $token) { 153 | $tokenInfo = self::parseToken($token); 154 | $txtlogController = new TxtlogController(); 155 | $found = false; 156 | 157 | foreach($txtlog->getTokens() ?: [] as $key=>$validToken) { 158 | if($validToken->token == $tokenInfo->token) { 159 | // Refresh because tokens are stored denormalized 160 | $txtlog = $txtlogController->getByID($txtlog->getID(), useCache: false); 161 | $tokens = $txtlog->getTokens(); 162 | unset($tokens[$key]); 163 | $txtlog->setTokens($tokens); 164 | 165 | // Require at least one admin token 166 | $hasAdminToken = false; 167 | foreach($txtlog->getTokens() as $newToken) { 168 | if($newToken->priv == Priv::Admin) { 169 | $hasAdminToken = true; 170 | } 171 | } 172 | if(!$hasAdminToken) { 173 | throw new Exception('ERROR_INVALID_ACTION'); 174 | } 175 | 176 | $txtlogController->update($txtlog); 177 | $found = true; 178 | } 179 | } 180 | 181 | if(!$found) { 182 | throw new Exception('ERROR_NOT_FOUND'); 183 | } 184 | 185 | return [ 186 | 'status'=>'success', 187 | 'detail'=>"Removed token $token" 188 | ]; 189 | } 190 | 191 | 192 | /** 193 | * Get a list of all existing tokens 194 | * 195 | * @param txtlog object of the existing log 196 | * @throws Exception error message 197 | * @return array 198 | */ 199 | public static function getExistingTokens($txtlog) { 200 | $result = []; 201 | 202 | foreach($txtlog->getTokens() ?: [] as $validToken) { 203 | $tokenInfo = [ 204 | 'privilege'=>strtolower($validToken->priv->name), 205 | 'token'=>$validToken->token 206 | ]; 207 | 208 | if(isset($validToken->get)) { 209 | $tokenInfo['search'] = str_replace('[]', '', $validToken->get); 210 | } 211 | 212 | $result[] = $tokenInfo; 213 | } 214 | 215 | return $result; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /txtlog/model/accountdb.php: -------------------------------------------------------------------------------- 1 | execute('SELECT ' 15 | .'ID, ' 16 | .'CreationDate, ' 17 | .'ModifyDate, ' 18 | .'Name, ' 19 | .'QueryTimeout, ' 20 | .'MaxIPUsage, ' 21 | .'MaxRetention, ' 22 | .'MaxRows, ' 23 | .'MaxRowSize, ' 24 | .'DashboardRows, ' 25 | .'DashboardRowsSearch, ' 26 | .'Price, ' 27 | .'PaymentLink ' 28 | .'FROM Account ' 29 | .'ORDER BY ID' 30 | ); 31 | 32 | $outputAccounts = []; 33 | foreach($records as $record) { 34 | $outputAccount = new AccountEntity(); 35 | $outputAccount->setFromDB($record); 36 | $outputAccounts[] = $outputAccount; 37 | } 38 | 39 | return $outputAccounts; 40 | } 41 | 42 | 43 | /** 44 | * Insert a new account 45 | * 46 | * @param account object 47 | * @return void 48 | */ 49 | public function insert($account) { 50 | $this->execute('INSERT INTO Account SET ' 51 | .'Name=?, ' 52 | .'QueryTimeout=?, ' 53 | .'MaxIPUsage=?, ' 54 | .'MaxRetention=?, ' 55 | .'MaxRows=?, ' 56 | .'MaxRowsize=?, ' 57 | .'DashboardRows=?, ' 58 | .'DashboardRowsSearch=?, ' 59 | .'Price=?, ' 60 | .'PaymentLink=?', 61 | [ 62 | $account->getName(), 63 | $account->getQueryTimeout(), 64 | $account->getMaxIPUsage(), 65 | $account->getMaxRetention(), 66 | $account->getMaxRows(), 67 | $account->getMaxRowSize(), 68 | $account->getDashboardRows(), 69 | $account->getDashboardRowsSearch(), 70 | $account->getPrice(), 71 | $account->getPaymentLink() 72 | ] 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /txtlog/model/clickhousedb.php: -------------------------------------------------------------------------------- 1 | get($serverID); 16 | 17 | // Use specified server settings 18 | $dbhost = $server->getHostname(); 19 | $dbname = $server->getDBName(); 20 | $dbport = $server->getPort(); 21 | $dbuser = $server->getUsername(); 22 | $dbpass = $server->getPassword(); 23 | $dboptions = $server->getOptions(); 24 | 25 | $this->open($dbhost, $dbname, $dbport, $dbuser, $dbpass, $dboptions); 26 | 27 | return (object)[ 28 | 'host'=>$dbhost, 29 | 'name'=>$dbname, 30 | 'port'=>$dbport, 31 | 'user'=>$dbuser 32 | ]; 33 | } 34 | 35 | 36 | /** 37 | * Get recently executes ClickHouse queries 38 | * 39 | * @return object 40 | */ 41 | public function getRecentQueries() { 42 | return $this->execute("SELECT query_start_time_microseconds, " 43 | ."round(memory_usage/1024/1024, 2) AS Memory, " 44 | ."query_duration_ms/1000 as query_duration_sec, " 45 | ."LEFT(query, 1000) AS query " 46 | ."FROM system.query_log " 47 | ."WHERE type='QueryFinish' " 48 | ."ORDER BY query_start_time DESC " 49 | ."LIMIT 50"); 50 | } 51 | 52 | 53 | /** 54 | * Get TxtlogRow table statistics 55 | * 56 | * @return object 57 | */ 58 | public function getTableStats() { 59 | return $this->getRow("SELECT " 60 | ."formatReadableSize(sum(data_compressed_bytes) AS size) AS Compressed, " 61 | ."formatReadableSize(sum(data_uncompressed_bytes) AS usize) AS Uncompressed, " 62 | ."round(usize / size, 2) AS Compression_rate, " 63 | ."SUM(rows) AS Rows, " 64 | ."COUNT() AS Part_count, " 65 | ."formatReadableSize(SUM(primary_key_bytes_in_memory)) AS Primary_key_bytes_in_memory " 66 | ."FROM system.parts " 67 | ."WHERE database='txtlog' " 68 | ."AND active=1 " 69 | ."AND table='TxtlogRow'"); 70 | } 71 | 72 | 73 | /** 74 | * Get TxtlogRow column statistics 75 | * 76 | * @return object 77 | */ 78 | public function getColumnStats() { 79 | return $this->execute("SELECT " 80 | ."column, " 81 | ."formatReadableSize(SUM(column_data_compressed_bytes) AS Size) AS Compressed, " 82 | ."formatReadableSize(SUM(column_data_uncompressed_bytes) AS Usize) AS Uncompressed, " 83 | ."round(Usize / Size, 2) AS Compression_rate, " 84 | ."sum(rows) AS Rows, " 85 | ."round(Usize / Rows, 2) AS Avg_row_size " 86 | ."FROM system.parts_columns " 87 | ."WHERE table='TxtlogRow' " 88 | ."AND active=1 " 89 | ."GROUP BY column " 90 | ."ORDER BY Size DESC"); 91 | } 92 | 93 | 94 | /** 95 | * Get disk information 96 | * 97 | * @return object 98 | */ 99 | public function getDiskStats() { 100 | return $this->execute('SELECT ' 101 | .'name, ' 102 | .'path, ' 103 | .'formatReadableSize(free_space) AS Free, ' 104 | .'formatReadableSize(total_space) AS Total, ' 105 | .'formatReadableSize(keep_free_space) AS Reserved ' 106 | .'FROM system.disks'); 107 | } 108 | 109 | 110 | /** 111 | * Get TxtlogRow index statistics 112 | * 113 | * @return object 114 | */ 115 | public function getIndexStats() { 116 | return $this->execute("SELECT " 117 | ."name, " 118 | ."type_full, " 119 | ."expr, " 120 | ."granularity, " 121 | ."formatReadableSize(data_compressed_bytes) AS Compressed, " 122 | ."formatReadableSize(data_uncompressed_bytes) AS Uncompressed, " 123 | ."marks " 124 | ."FROM system.data_skipping_indices " 125 | ."WHERE table='TxtlogRow'"); 126 | } 127 | 128 | 129 | /** 130 | * Get TxtlogRows with most rows per Txtlog 131 | * 132 | * @return object 133 | */ 134 | public function getLargestLogs() { 135 | return $this->execute('SELECT ' 136 | .'TxtlogID, ' 137 | .'COUNT() AS Rows ' 138 | .'FROM TxtlogRow ' 139 | .'GROUP BY TxtlogID ' 140 | .'ORDER BY COUNT() DESC ' 141 | .'LIMIT 50'); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /txtlog/model/paymentdb.php: -------------------------------------------------------------------------------- 1 | execute('SELECT ' 16 | .'ID, ' 17 | .'CreationDate, ' 18 | .'SessionID, ' 19 | .'Data ' 20 | .'FROM Payment ' 21 | .'ORDER BY ID DESC ' 22 | ."LIMIT $limit" 23 | ); 24 | 25 | $outputPayments = []; 26 | foreach($records as $record) { 27 | $outputPayment = new PaymentEntity(); 28 | $outputPayment->setFromDB($record); 29 | $outputPayments[] = $outputPayment; 30 | } 31 | 32 | return $outputPayments; 33 | } 34 | 35 | 36 | /** 37 | * Get payment object by session ID 38 | * 39 | * @return Payment object 40 | */ 41 | public function get($sessionID) { 42 | return $this->getRow('SELECT ' 43 | .'ID, ' 44 | .'CreationDate, ' 45 | .'SessionID, ' 46 | .'Data ' 47 | .'FROM Payment ' 48 | .'WHERE SessionID=?', 49 | $sessionID 50 | ); 51 | } 52 | 53 | 54 | /** 55 | * Insert a new Payment 56 | * 57 | * @param payment object with the data to insert 58 | * @return void 59 | */ 60 | public function insert($payment) { 61 | $result = $this->execute('INSERT INTO Payment ' 62 | .'(SessionID, Data) ' 63 | .'SELECT ' 64 | .'?, ? ' 65 | .'WHERE NOT EXISTS (SELECT * FROM Payment WHERE SessionID=?)', 66 | [ 67 | $payment->getSessionID(), 68 | $payment->getData(), 69 | $payment->getSessionID() 70 | ] 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /txtlog/model/serverdb.php: -------------------------------------------------------------------------------- 1 | execute('SELECT ' 15 | .'ID, ' 16 | .'CreationDate, ' 17 | .'ModifyDate, ' 18 | .'Active, ' 19 | .'Hostname, ' 20 | .'DBName, ' 21 | .'Port, ' 22 | .'Username, ' 23 | .'Password, ' 24 | .'Options ' 25 | .'FROM Server ' 26 | .'ORDER BY ID' 27 | ); 28 | 29 | $outputServers = []; 30 | foreach($records as $record) { 31 | $outputServer = new ServerEntity(); 32 | $outputServer->setFromDB($record); 33 | $outputServers[] = $outputServer; 34 | } 35 | 36 | return $outputServers; 37 | } 38 | 39 | 40 | /** 41 | * Insert a new server 42 | * 43 | * @param server object 44 | * @return void 45 | */ 46 | public function insert($server) { 47 | $this->execute('INSERT INTO Server SET ' 48 | .'ID=?, ' 49 | .'Active=?, ' 50 | .'Hostname=?, ' 51 | .'DBName=?, ' 52 | .'Port=?, ' 53 | .'Username=?, ' 54 | .'Password=?, ' 55 | .'Options=?', 56 | [ 57 | $server->getID(), 58 | $server->getActive() ?: null, 59 | $server->getHostname(), 60 | $server->getDBName(), 61 | $server->getPort(), 62 | $server->getUsername(), 63 | $server->getPassword(), 64 | $server->getOptionsString() 65 | ] 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /txtlog/model/settingsdb.php: -------------------------------------------------------------------------------- 1 | getRow('SELECT ' 18 | .'ID, ' 19 | .'CreationDate, ' 20 | .'ModifyDate, ' 21 | .'AnonymousAccountID, ' 22 | .'Pro1AccountID, ' 23 | .'Pro2AccountID, ' 24 | .'PaymentApiUrl, ' 25 | .'PaymentApiKey, ' 26 | .'Sitename, ' 27 | .'IncomingLogDomain, ' 28 | .'Email, ' 29 | .'CronEnabled, ' 30 | .'CronInsert, ' 31 | .'TempDir, ' 32 | .'AdminUser, ' 33 | .'AdminPassword, ' 34 | .'DemoAdminToken, ' 35 | .'DemoViewURL, ' 36 | .'DemoDashboardURL, ' 37 | .'MaxRetention, ' 38 | .'MaxAPICallsLog, ' 39 | .'MaxAPICallsGet, ' 40 | .'MaxAPIFails ' 41 | .'FROM Settings ' 42 | .'WHERE ' 43 | .'ID=1' 44 | ); 45 | 46 | $outputSettings->setFromDB($record); 47 | 48 | return $outputSettings; 49 | } 50 | 51 | 52 | /** 53 | * Insert a new settings entry 54 | * 55 | * @param settings object 56 | * @return void 57 | */ 58 | public function insert($settings) { 59 | $this->execute('INSERT INTO Settings SET ' 60 | .'AnonymousAccountID=?, ' 61 | .'Pro1AccountID=?, ' 62 | .'Pro2AccountID=?, ' 63 | .'PaymentApiUrl=?, ' 64 | .'PaymentApiKey=?, ' 65 | .'Sitename=?, ' 66 | .'IncomingLogDomain=?, ' 67 | .'Email=?, ' 68 | .'CronEnabled=?, ' 69 | .'CronInsert=?, ' 70 | .'TempDir=?, ' 71 | .'AdminUser=?, ' 72 | .'AdminPassword=?, ' 73 | .'DemoAdminToken=?, ' 74 | .'DemoViewURL=?, ' 75 | .'DemoDashboardURL=?, ' 76 | .'MaxRetention=?, ' 77 | .'MaxAPICallsLog=?, ' 78 | .'MaxAPICallsGet=?, ' 79 | .'MaxAPIFails=?', 80 | [ 81 | $settings->getAnonymousAccountID(), 82 | $settings->getPro1AccountID(), 83 | $settings->getPro2AccountID(), 84 | $settings->getPaymentApiUrl(), 85 | $settings->getPaymentApiKey(), 86 | $settings->getSitename(), 87 | $settings->getIncomingLogDomain(), 88 | $settings->getEmail(), 89 | $settings->getCronEnabled(), 90 | $settings->getCronInsert(), 91 | $settings->getTempDir(), 92 | $settings->getAdminUser(), 93 | $settings->getAdminPassword(), 94 | $settings->getDemoAdminToken(), 95 | $settings->getDemoViewURL(), 96 | $settings->getDemoDashboardURL(), 97 | $settings->getMaxRetention(), 98 | $settings->getMaxAPICallsLog(), 99 | $settings->getMaxAPICallsGet(), 100 | $settings->getMaxAPIFails() 101 | ] 102 | ); 103 | } 104 | 105 | 106 | /** 107 | * Update the settings 108 | * 109 | * @param settings object 110 | * @return void 111 | */ 112 | public function update($settings) { 113 | $this->execute('UPDATE Settings SET ' 114 | .'AnonymousAccountID=?, ' 115 | .'Pro1AccountID=?, ' 116 | .'Pro2AccountID=?, ' 117 | .'PaymentApiUrl=?, ' 118 | .'PaymentApiKey=?, ' 119 | .'Sitename=?, ' 120 | .'IncomingLogDomain=?, ' 121 | .'Email=?, ' 122 | .'CronEnabled=?, ' 123 | .'CronInsert=?, ' 124 | .'TempDir=?, ' 125 | .'AdminUser=?, ' 126 | .'AdminPassword=?, ' 127 | .'DemoAdminToken=?, ' 128 | .'DemoViewURL=?, ' 129 | .'DemoDashboardURL=?, ' 130 | .'MaxRetention=?, ' 131 | .'MaxAPICallsLog=?, ' 132 | .'MaxAPICallsGet=?, ' 133 | .'MaxAPIFails=? ' 134 | .'WHERE ID=?', 135 | [ 136 | $settings->getAnonymousAccountID(), 137 | $settings->getPro1AccountID(), 138 | $settings->getPro2AccountID(), 139 | $settings->getPaymentApiUrl(), 140 | $settings->getPaymentApiKey(), 141 | $settings->getSitename(), 142 | $settings->getIncomingLogDomain(), 143 | $settings->getEmail(), 144 | $settings->getCronEnabled(), 145 | $settings->getCronInsert(), 146 | $settings->getTempDir(), 147 | $settings->getAdminUser(), 148 | $settings->getAdminPassword(), 149 | $settings->getDemoAdminToken(), 150 | $settings->getDemoViewURL(), 151 | $settings->getDemoDashboardURL(), 152 | $settings->getMaxRetention(), 153 | $settings->getMaxAPICallsLog(), 154 | $settings->getMaxAPICallsGet(), 155 | $settings->getMaxAPIFails(), 156 | $settings->getID() 157 | ] 158 | ); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /txtlog/model/txtlogdb.php: -------------------------------------------------------------------------------- 1 | execute('SELECT ' 16 | .'ID, ' 17 | .'ModifyDate, ' 18 | .'AccountID, ' 19 | .'ServerID, ' 20 | .'Retention, ' 21 | .'Name, ' 22 | .'INET6_NTOA(IPAddress) AS IPAddress, ' 23 | .'HEX(UserHash) AS UserHash, ' 24 | .'Password, ' 25 | .'Tokens ' 26 | .'FROM Txtlog ' 27 | .'ORDER BY ID DESC ' 28 | ."LIMIT $limit" 29 | ); 30 | 31 | $outputTxtlogs = []; 32 | foreach($records as $record) { 33 | $outputTxtlog = new TxtlogEntity(); 34 | $outputTxtlog->setFromDB($record); 35 | $outputTxtlogs[] = $outputTxtlog; 36 | } 37 | 38 | return $outputTxtlogs; 39 | } 40 | 41 | 42 | /** 43 | * Get a single Txtlog by ID or code 44 | * 45 | * @param id 46 | * @param userHash optional to select on userHash instead of ID 47 | * @return Txtlog object 48 | */ 49 | public function get($id, $userHash=null) { 50 | $outputTxtlog = new TxtlogEntity(); 51 | 52 | $query = 'SELECT ' 53 | .'ID, ' 54 | .'ModifyDate, ' 55 | .'AccountID, ' 56 | .'ServerID, ' 57 | .'Retention, ' 58 | .'Name, ' 59 | .'INET6_NTOA(IPAddress) AS IPAddress, ' 60 | .'HEX(UserHash) AS UserHash, ' 61 | .'Password, ' 62 | .'Tokens ' 63 | .'FROM Txtlog ' 64 | .'WHERE '; 65 | 66 | if(!is_null($userHash)) { 67 | $query .= 'UserHash=UNHEX(?)'; 68 | $param = $userHash; 69 | } else { 70 | $query .= 'ID=?'; 71 | $param = $id; 72 | } 73 | 74 | $record = $this->getRow($query, $param); 75 | 76 | $outputTxtlog->setFromDB($record); 77 | 78 | return $outputTxtlog; 79 | } 80 | 81 | 82 | /** 83 | * Insert a new Txtlog 84 | * 85 | * @param txtlog object with the data to insert 86 | * @return void 87 | */ 88 | public function insert($txtlog) { 89 | $result = $this->execute('INSERT INTO Txtlog SET ' 90 | .'ID=?, ' 91 | .'AccountID=?, ' 92 | .'ServerID=?, ' 93 | .'Retention=?, ' 94 | .'Name=?, ' 95 | .'IPAddress=INET6_ATON(?), ' 96 | .'UserHash=UNHEX(?), ' 97 | .'Password=?, ' 98 | .'Tokens=?', 99 | [ 100 | $txtlog->getID(), 101 | $txtlog->getAccountID(), 102 | $txtlog->getServerID(), 103 | $txtlog->getRetention(), 104 | $txtlog->getName(), 105 | $txtlog->getIPAddress(), 106 | $txtlog->getUserHash(), 107 | $txtlog->getPassword(), 108 | $txtlog->getTokensString() 109 | ], 110 | 'ERROR_USERNAME_ALREADY_EXISTS' 111 | ); 112 | } 113 | 114 | 115 | /** 116 | * Update an existing Txtlog 117 | * 118 | * @param txtlog object with the new data 119 | * @return void 120 | */ 121 | protected function update($txtlog) { 122 | $result = $this->execute('UPDATE Txtlog SET ' 123 | .'AccountID=?, ' 124 | .'ServerID=?, ' 125 | .'Retention=?, ' 126 | .'Name=?, ' 127 | .'IPAddress=INET6_ATON(?), ' 128 | .'UserHash=UNHEX(?), ' 129 | .'Password=?, ' 130 | .'Tokens=? ' 131 | .'WHERE ID=?', 132 | [ 133 | $txtlog->getAccountID(), 134 | $txtlog->getServerID(), 135 | $txtlog->getRetention(), 136 | $txtlog->getName(), 137 | $txtlog->getIPAddress(), 138 | $txtlog->getUserHash(), 139 | $txtlog->getPassword(), 140 | $txtlog->getTokensString(), 141 | $txtlog->getID() 142 | ], 143 | 'ERROR_USERNAME_ALREADY_EXISTS' 144 | ); 145 | } 146 | 147 | 148 | /** 149 | * Delete a Txtlog 150 | * 151 | * @param txtlog object 152 | * @return void 153 | */ 154 | protected function delete($txtlog) { 155 | $this->execute('DELETE FROM Txtlog ' 156 | .'WHERE ID=?', 157 | $txtlog->getID() 158 | ); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /txtlog/model/txtlogrowdb.php: -------------------------------------------------------------------------------- 1 | get($serverID); 25 | 26 | // Use specified server settings 27 | $dbhost = $server->getHostname(); 28 | $dbname = $server->getDBName(); 29 | $dbport = $server->getPort(); 30 | $dbuser = $server->getUsername(); 31 | $dbpass = $server->getPassword(); 32 | $dboptions = $server->getOptions(); 33 | 34 | // On failure continue the script, used in the cron job to prevent one server down from stopping the script 35 | $exitOnError = false; 36 | 37 | $this->open($dbhost, $dbname, $dbport, $dbuser, $dbpass, $dboptions, $exitOnError); 38 | } 39 | 40 | 41 | /** 42 | * Get the timeout status of the most recent select query 43 | * 44 | * @return bool 45 | */ 46 | public function getTimeout() { 47 | return $this->timeout; 48 | } 49 | 50 | 51 | /** 52 | * Count the number of rows for a given Txtlog ID 53 | * 54 | * @param txtlogID 55 | * @return int row count 56 | */ 57 | public function count($txtlogID) { 58 | return $this->getRow('SELECT ' 59 | .'COUNT() AS Rows ' 60 | .'FROM TxtlogRow ' 61 | .'WHERE TxtlogID=?', $txtlogID)->Rows; 62 | } 63 | 64 | 65 | /** 66 | * Get all TxtlogRow rows for a given Txtlog ID 67 | * 68 | * @param txtlogID 69 | * @param limit, retrieve max this number of rows 70 | * @param search object with search keys and values 71 | * @param timeout cancel the query after this many seconds (fractions are possible) and return the partial results 72 | * @return array of TxtlogRow objects 73 | */ 74 | protected function get($txtlogID, $limit, $search, $timeout) { 75 | $this->timeout = false; 76 | $order = 'DESC'; 77 | $params = [$txtlogID]; 78 | $where = ''; 79 | $txtlogRow = new TxtlogRowEntity(); 80 | 81 | foreach($search->data as $data) { 82 | if(strlen($data) > 0) { 83 | $where .= 'AND hasTokenCaseInsensitiveOrNull(Data, ?) '; 84 | $params[] = mb_strtolower($data, 'UTF-8'); 85 | } 86 | } 87 | 88 | foreach($search->searchfields as $searchfield) { 89 | if(strlen($searchfield) > 0) { 90 | $where .= 'AND hasTokenCaseInsensitiveOrNull(SearchFields, ?) '; 91 | $params[] = mb_strtolower($searchfield, 'UTF-8'); 92 | } 93 | } 94 | 95 | if(strlen($search->date) > 0) { 96 | $where .= 'AND ID <= UNHEX(?) '; 97 | $params[] = $search->date; 98 | } 99 | 100 | if(strlen($search->before) > 0) { 101 | $where .= 'AND ID < UNHEX(?) '; 102 | $params[] = $search->before; 103 | } 104 | 105 | if(strlen($search->after) > 0) { 106 | $where .= 'AND ID > UNHEX(?) '; 107 | $params[] = $search->after; 108 | $order = 'ASC'; 109 | } 110 | 111 | /* This needs to be done with a CTE because after max_execution_time an exception is thrown and no data is returned when using PDO. 112 | * Using "timeout_overflow_mode='break'" causes PHP/Apache to wait for results never coming, resulting in a Gateway timeout after 5 minutes (default) 113 | */ 114 | $start = microtime(true); 115 | $records = $this->execute('WITH cte_result AS (SELECT ' 116 | .'HEX(ID) AS HexID, ' 117 | .'Data ' 118 | .'FROM TxtlogRow ' 119 | .'WHERE TxtlogID=? ' 120 | .$where 121 | ."ORDER BY ID $order " 122 | ."LIMIT $limit " 123 | ."SETTINGS " 124 | ."max_execution_time=$timeout, " 125 | ."timeout_before_checking_execution_speed=0, " 126 | ."timeout_overflow_mode='break') " 127 | ."SELECT * FROM cte_result", 128 | $params 129 | ); 130 | $this->timeout = microtime(true) - $start >= $timeout; 131 | 132 | $outputTxtlogRows = []; 133 | foreach($records as $record) { 134 | $outputTxtlogRow = new TxtlogRowEntity(); 135 | $outputTxtlogRow->setFromDB($record); 136 | $outputTxtlogRows[] = $outputTxtlogRow; 137 | } 138 | 139 | return $order == 'ASC' ? array_reverse($outputTxtlogRows) : $outputTxtlogRows; 140 | } 141 | 142 | 143 | /** 144 | * Insert multiple new rows 145 | * 146 | * @param txtlogRows array with TxtlogRow objects 147 | * @return void 148 | */ 149 | public function insertMultiple($txtlogRows) { 150 | $data = []; 151 | 152 | if(empty($txtlogRows)) { 153 | return; 154 | } 155 | 156 | foreach($txtlogRows as $txtlogRow) { 157 | $data[] = $txtlogRow->getTxtlogID(); 158 | $data[] = $txtlogRow->getID(); 159 | $data[] = $txtlogRow->getTimestamp(); 160 | $data[] = $txtlogRow->getSearchFields(); 161 | $data[] = $txtlogRow->getData(); 162 | } 163 | 164 | $this->execute('INSERT INTO TxtlogRow ' 165 | .'(TxtlogID, ID, Date, SearchFields, Data) ' 166 | .'VALUES ' 167 | .implode(',', array_fill(0, count($txtlogRows), '(?,UNHEX(?),CAST(? AS UInt64),?,?)')), 168 | $data, 169 | "ERROR_INSERTING_ROWS" 170 | ); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /txtlog/router.php: -------------------------------------------------------------------------------- 1 | ['title'=>'Account'], 4 | 'admin'=>['title'=>'Admin'], 5 | 'api'=>['header'=>false, 'footer'=>false], 6 | 'createdemo'=>['header'=>false, 'footer'=>false], 7 | 'cron'=>['header'=>false, 'footer'=>false], 8 | 'doc'=>['title'=>'Documentation'], 9 | 'faq'=>['title'=>'F.A.Q.'], 10 | 'index'=>['title'=>'Txtlog'], 11 | 'install'=>'', 12 | 'pricing'=>['title'=>'Pricing'], 13 | 'privacy'=>['title'=>'Privacy policy'], 14 | 'selfhost'=>['title'=>'Self hosting'], 15 | 'txtlog'=>['header'=>false, 'footer'=>false] 16 | ]; 17 | -------------------------------------------------------------------------------- /txtlog/scripts/faker.js: -------------------------------------------------------------------------------- 1 | const { faker } = require('@faker-js/faker'); 2 | 3 | // Set these variables 4 | var multiplier = 2000; 5 | 6 | // Optional tweaks 7 | var userCount = 10 * multiplier; 8 | var ipCount = 5 * multiplier; 9 | var uuidCount = 100 * multiplier; 10 | var companyCount = 10 * multiplier; 11 | var urlCount = 5 * multiplier; 12 | var browserCount = multiplier; 13 | 14 | var result = " O\'Connell 50 | arr[i] = arr[i].replace(/'/g, "\\'"); 51 | } 52 | return ' public static $' + name + " = ['" + arr.join("','") + "'];\n\n"; 53 | } 54 | 55 | result += "}"; 56 | console.log(result); 57 | -------------------------------------------------------------------------------- /txtlog/scripts/txtlog: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | AUTH=$REPLACE_AUTH_CODE 3 | 4 | TXTLOG=/usr/local/bin/txtlog 5 | PAMFILE=/etc/pam.d/common-auth 6 | 7 | # This script should be run as root because it creates a small script in /usr/local/bin and modifies PAM files 8 | if [ `id -u` != "0" ]; then 9 | echo "Please run this script as root" 10 | echo "sudo /bin/bash ./txtlog" 11 | exit 1 12 | fi 13 | 14 | if [ ! -f $PAMFILE ]; then 15 | echo "PAM not found, aborting" 16 | exit 2 17 | fi 18 | 19 | if [ -f $TXTLOG ]; then 20 | echo "The txtlog script already exists, if you want to recreate it delete $TXTLOG and run this script again:" 21 | echo "sudo rm -f $TXTLOG" 22 | exit 3 23 | fi 24 | 25 | cat > $TXTLOG << 'EOF' 26 | #!/bin/bash 27 | URL=$REPLACE_DOMAIN/api/log/ 28 | AUTHORIZATION=$1 29 | 30 | # Uncomment this to log cron as well 31 | if [ "$PAM_SERVICE" == "cron" ]; then exit; fi 32 | 33 | DATE=$(date +"%Y-%m-%dT%H:%M:%S.%3N") 34 | 35 | ACTION=$PAM_TYPE 36 | ACTION="${ACTION/open_session/login}" 37 | ACTION="${ACTION/close_session/logout}" 38 | 39 | USER=$PAM_RUSER 40 | if [ -n $PAM_USER ]; then 41 | [ $PAM_RUSER ] && USER=$PAM_RUSER/$PAM_USER || USER=$PAM_USER 42 | fi 43 | USER="${USER//[ \-\"\']/}" 44 | HOST=$(hostname) 45 | HOST="${HOST//[ \-\"\']/}" 46 | 47 | curl \ 48 | -H "Authorization: $AUTHORIZATION" \ 49 | $URL \ 50 | --data-binary '{"date":"'$DATE'","service":"'$PAM_SERVICE'","action":"'$ACTION'","user":"'$USER'","ip":"'$PAM_RHOST'","serverhostname":"'$HOST'"}' \ 51 | || true 52 | EOF 53 | 54 | chmod 700 $TXTLOG 55 | 56 | echo "Created script $TXTLOG" 57 | 58 | if grep -q $TXTLOG $PAMFILE; then 59 | echo "$PAMFILE already contains the necessary scripts" 60 | else 61 | echo "Modifying $PAMFILE" 62 | echo -e "\n# Log common auth info using txtlog\nsession optional pam_exec.so seteuid $TXTLOG $AUTH" >> $PAMFILE 63 | fi 64 | 65 | echo "Install complete" 66 | -------------------------------------------------------------------------------- /txtlog/scripts/txtlog.ps1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/txtlog/scripts/txtlog.ps1 -------------------------------------------------------------------------------- /txtlog/settings.php: -------------------------------------------------------------------------------- 1 | get()->getSitename(); 7 | $hostname = "https://$sitename"; 8 | ?> 9 |
10 | getAccessDenied()?> 11 | 12 |
13 |
14 | You are logged in as (logout). Logout successful
15 | Account:
16 | Retention: days
17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 | 27 |
28 | 29 |
30 |
31 |

Login

32 |
33 | 34 |
35 |
36 | 37 |
38 |
39 | 40 |
41 |
42 |
43 | Click here to generate a new log. 44 |
45 |
46 | New log created, return to the homepage. 47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /txtlog/web/accountinfo.php: -------------------------------------------------------------------------------- 1 | get()->getLogDomain(); 8 | ?> 9 | 10 |
11 |
12 | Application logs 13 |
14 | 15 |
16 | Log anything from a web or mobile application by sending a POST request with the log data.
17 | The documentation has examples for popular programming languages. 18 |
19 | 20 |
21 | 29 |
30 |
31 | 32 | 33 |
34 |
35 |

36 |       
37 |     
38 |
39 | 40 |
41 |
42 | Monitor server logins 43 |
44 | 45 |
46 | For Linux servers, download the open source txtlog script to log all local and SSH logins.
47 | Install the script on each server you want to monitor: 48 |
49 | sudo /bin/bash ./txtlog 50 |
51 | On Windows, download the open source txtlog script to monitor RDP logins. Right click and choose "Run with PowerShell". 52 |
53 |
54 | 55 |
56 |
57 | The txtlog scripts contains comments to explain what happens and should be relatively easy to comprehend, the faq explains in more detail how it works.
58 |
59 |
60 | 61 | -------------------------------------------------------------------------------- /txtlog/web/admin.php: -------------------------------------------------------------------------------- 1 | '; 8 | 9 | $cache = new Cache(); 10 | $login = new Login(); 11 | $inputUser = Common::post('username', 200); 12 | $inputPass = Common::post('password', 200); 13 | $serverInput = Common::get('server'); 14 | $serverID = Common::isInt($serverInput, 1, 255) ? $serverInput : 1; 15 | $settings = (new Settings)->get(); 16 | 17 | try { 18 | $cache->verifyIPFails(); 19 | } catch(Exception $e) { 20 | echo $login->getAccessDenied(false); 21 | exit; 22 | } 23 | 24 | if(strlen($inputPass) < 1) { 25 | echo $login->getLoginAdmin(); 26 | } elseif($inputUser == $settings->getAdminUser() && password_verify($inputPass, $settings->getAdminPassword())) { 27 | echo $login->showAdmin($serverID); 28 | } else { 29 | $cache->addIPFail(); 30 | echo $login->getLoginAdmin(); 31 | echo $login->getAccessDenied(false); 32 | } 33 | -------------------------------------------------------------------------------- /txtlog/web/api.php: -------------------------------------------------------------------------------- 1 | verifyMethod(); 10 | $api->verifyLimit(); 11 | $output = $api->parseRequest(); 12 | } catch(ConnectionException $eConn) { 13 | $errorMsg = 'ERROR_SERVICE_UNAVAILABLE'; 14 | $output = (object)['error'=>$errorMsg]; 15 | $api->setHttpCode($errorMsg); 16 | } catch(Exception $e) { 17 | $errorMsg = $e->getMessage(); 18 | $output = (object)['error'=>$errorMsg]; 19 | // Set the HTTP status code based on the error message, defined in includes/error.php 20 | $api->setHttpCode($errorMsg); 21 | } 22 | 23 | // Send the correct HTTP status code, header and output 24 | $api->sendHttpCode(); 25 | $api->setContentHeader(); 26 | $api->setContent($output); 27 | -------------------------------------------------------------------------------- /txtlog/web/createdemo.php: -------------------------------------------------------------------------------- 1 | checkAccess(); 10 | 11 | if(!class_exists('Txtlog\Includes\Testdata', true)) { 12 | echo "Generate fake data first, e.g.\nnode faker.js > /var/www/txtlog/txtlog/includes/testdata.php\n"; 13 | exit; 14 | } 15 | 16 | // Insert this many records 17 | $limit = 10000; 18 | $userCount = 5; 19 | $ipCount = 2; 20 | 21 | $limitInput = Common::get('limit', 6); 22 | $limit = Common::isInt($limitInput, 1, $limit * 10) ? $limitInput : $limit; 23 | $debug = Common::get('debug') == 'true'; 24 | $sitename = (new Settings)->get()->getSitename(); 25 | $past = strtotime('-1 day'); 26 | $future = strtotime('+1 day'); 27 | $data = []; 28 | $baseUrl = "https://$sitename/api/log"; 29 | $logUrl = (new Settings)->get()->getLogDomain().'/api/log'; 30 | $tokenUrl = "https://$sitename/api/token"; 31 | 32 | $service = ['sshd', 'cron', 'rdplogin', 'rdplogout', 'login', 'logout', 'client/', 'payment/', 'user/', 'item/', 'product/', 'api/', 'fetch/', 'cgi-bin/', 'srv/', 'private/']; 33 | $methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; 34 | 35 | // Select some records from the random data 36 | $users = []; 37 | $ips = []; 38 | $browsers = []; 39 | for($i=0; $i<$userCount; $i++) { 40 | $users[] = Testdata::$users[array_rand(Testdata::$users)]; 41 | } 42 | for($i=0; $i<$ipCount; $i++) { 43 | $ips[] = Testdata::$ips[array_rand(Testdata::$ips)]; 44 | } 45 | // faker.js browser are often incompatible with PHP get_browser 46 | $browsers = [ 47 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 48 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0', 49 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0', 50 | 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', 51 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.3', 52 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.1' 53 | ]; 54 | 55 | for($i=0; $i<$limit; $i++) { 56 | $json = []; 57 | 58 | $json['service'] = $service[array_rand($service)]; 59 | 60 | if(rand(0,19) < 19) { 61 | $json['date'] = (new DateTimeImmutable())->setTimestamp(rand($past, $future))->format('Y-m-d\TH:i:s\Z'); 62 | } 63 | 64 | if($json['service'] == 'rdplogin' || $json['service'] == 'rdplogout') { 65 | $json['computer'] = 'WIN-'.strtoupper(Common::getRandomString(rand(3, 5))); 66 | $json['ip'] = rand(0,999) == 0 ? Testdata::$ips[array_rand(Testdata::$ips)] : $ips[array_rand($ips)]; 67 | $json['event_id'] = $json['service'] == 'rdplogin' ? (rand(0, 2) == 0 ? 25 : 21) : (rand(0, 2) == 0 ? 24 : 23); 68 | $json['serverip'] = Testdata::$ips[array_rand(Testdata::$ips)]; 69 | $json['user'] = $json['computer'].'\\'.(rand(0, 3) == 0 ? 'Administrator' : $users[array_rand($users)]); 70 | } elseif($json['service'] == 'sshd' || $json['service'] == 'sshd') { 71 | $json['action'] = rand(0, 1) == 0 ? 'login' : 'logout'; 72 | $json['user'] = rand(0, 3) == 0 ? 'root' : $users[array_rand($users)]; 73 | $json['ip'] = rand(0,999) == 0 ? Testdata::$ips[array_rand(Testdata::$ips)] : $ips[array_rand($ips)]; 74 | $json['serverip'] = Testdata::$ips[array_rand(Testdata::$ips)]; 75 | $json['serverhostname'] = $sitename; 76 | } elseif($json['service'] == 'cron') { 77 | $json['action'] = rand(0, 1) == 0 ? 'login' : 'logout'; 78 | $json['user'] = rand(0, 3) == 0 ? 'root' : $users[array_rand($users)]; 79 | $json['ip'] = ''; 80 | $json['serverip'] = Testdata::$ips[array_rand(Testdata::$ips)]; 81 | $json['serverhostname'] = $sitename; 82 | } else { 83 | if(rand(0,19) < 19) { 84 | $json['user'] = $users[array_rand($users)]; 85 | } 86 | 87 | if(rand(0,10) < 9) { 88 | $json['browser'] = rand(0,200) == 0 ? Testdata::$browsers[array_rand(Testdata::$browsers)] : $browsers[array_rand($browsers)]; 89 | } 90 | 91 | if(substr($json['service'], -1) == '/') { 92 | $json['service'] .= rand(10,9999999); 93 | 94 | if(rand(0,9) < 8) { 95 | $json['method'] = rand(0,200) == 0 ? $methods[array_rand($methods)] : 'POST'; 96 | } 97 | 98 | if(rand(0,200) == 0) { 99 | $json['session_id'] = Common::getRandomString(rand(1, 30)); 100 | } 101 | } 102 | 103 | if(rand(0,2) == 0) { 104 | $json['url'] = Testdata::$urls[array_rand(Testdata::$urls)]; 105 | } 106 | 107 | if(rand(0,80) == 0) { 108 | $json['url2'] = Testdata::$urls[array_rand(Testdata::$urls)]; 109 | } 110 | 111 | if(rand(0,100) == 0) { 112 | $json['uptime'] = rand(1,10000). ' seconds'; 113 | } 114 | 115 | if(rand(0,10) < 9) { 116 | $json['ip'] = rand(0,999) == 0 ? Testdata::$ips[array_rand(Testdata::$ips)] : $ips[array_rand($ips)]; 117 | } 118 | 119 | if(rand(0,20) < 15) { 120 | $json['trace_id'] = Testdata::$uuids[array_rand(Testdata::$uuids)]; 121 | } 122 | 123 | if(rand(0,10) == 0) { 124 | $json['owner'] = Testdata::$companies[array_rand(Testdata::$companies)]; 125 | } 126 | } 127 | 128 | $data['rows'][] = $json; 129 | } 130 | 131 | if($debug) { 132 | echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 133 | exit; 134 | } 135 | 136 | $demoName = 'Demo page'; 137 | $demoAdminToken = (new Settings)->get()->getDemoAdminToken(); 138 | 139 | if(strlen($demoAdminToken) == 0) { 140 | echo "Creating new demo log\n"; 141 | 142 | $ch = curl_init($baseUrl); 143 | curl_setopt($ch, CURLOPT_POST, 1); 144 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 145 | $result = curl_exec($ch); 146 | $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); 147 | if($result === false || $status != 201) { 148 | echo "Curl exception (1), status $status\n"; 149 | exit; 150 | } 151 | curl_close($ch); 152 | 153 | $result = json_decode($result); 154 | 155 | // Store the admin and view URL in the settings 156 | $demoAdminToken = $result->admin; 157 | $s = (new Settings)->get(useCache: false); 158 | $s->setDemoAdminToken($demoAdminToken); 159 | $s->setDemoViewURL($result->view); 160 | 161 | try { 162 | (new Settings)->update($s); 163 | } catch(Exception $e) { 164 | echo "Error updating settings\n"; 165 | print_r($e); 166 | exit; 167 | } 168 | } 169 | 170 | // Post the data 171 | $ch = curl_init($logUrl); 172 | curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: $demoAdminToken"]); 173 | curl_setopt($ch, CURLOPT_POST, 1); 174 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 175 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); 176 | $result = curl_exec($ch); 177 | $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); 178 | if($result === false || $status != 202) { 179 | echo "Curl exception (2), status $status\n"; 180 | print_r($result); 181 | exit; 182 | } 183 | curl_close($ch); 184 | 185 | $demoDashboardURL = (new Settings)->get()->getDemoDashboardURL(); 186 | 187 | if(strlen($demoDashboardURL) == 0) { 188 | echo "Creating new public dashboard token\n"; 189 | 190 | $ch = curl_init($tokenUrl.'?data=POST+Safari'); 191 | curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: $demoAdminToken"]); 192 | curl_setopt($ch, CURLOPT_POST, 1); 193 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 194 | curl_setopt($ch, CURLOPT_POSTFIELDS, 'scope=view'); 195 | $tokenResult = curl_exec($ch); 196 | $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); 197 | if($tokenResult === false || $status != 201) { 198 | echo "Curl exception (3), status $status\n"; 199 | exit; 200 | } 201 | curl_close($ch); 202 | 203 | $tokenResult = json_decode($tokenResult); 204 | 205 | // Store the public dashboard URL in the settings 206 | $demoDashboardURL = $tokenResult->url; 207 | $s = (new Settings)->get(useCache: false); 208 | $s->setDemoDashboardURL($demoDashboardURL); 209 | 210 | try { 211 | (new Settings)->update($s); 212 | } catch(Exception $e) { 213 | echo "Error updating settings\n"; 214 | print_r($e); 215 | exit; 216 | } 217 | } 218 | 219 | echo "Demo page complete, URL: "; 220 | echo (new Settings)->get()->getDemoViewURL()."\n"; 221 | echo "Result:\n$result\n"; 222 | -------------------------------------------------------------------------------- /txtlog/web/cron.php: -------------------------------------------------------------------------------- 1 | checkAccess(); 7 | 8 | // Allow custom set runtimes, e.g. /cron?time=11 to run for 11 seconds 9 | $runtime = Common::isInt(Common::get('time'), 1, 600) ? Common::get('time') : 60; 10 | $action = ''; 11 | $getAction = Common::get('action') ?? ''; 12 | if($getAction == 'filetodatabase') { 13 | $action = 'fileToDatabase'; 14 | } elseif($getAction == 'cachetofile') { 15 | $action = 'cacheToFile'; 16 | } elseif($getAction == 'geoipdownload') { 17 | $action = 'geoipdownload'; 18 | } elseif($getAction == 'toripdownload') { 19 | $action = 'toripdownload'; 20 | } elseif($getAction == 'geoipparse') { 21 | $action = 'geoipparse'; 22 | } elseif($getAction == 'toripparse') { 23 | $action = 'toripparse'; 24 | } 25 | $updateCache = Common::get('updatecache') == 'true'; 26 | 27 | echo "Start cron job. Action=$action, updateCache=".($updateCache ? 'yes' : 'no').", runtime $runtime seconds: ".(new DateTime())->format('Y-m-d H:i:s.v')."\n"; 28 | 29 | if($action == 'fileToDatabase' || $action == 'cacheToFile') { 30 | $end = time() + $runtime; 31 | $done = false; 32 | while(!$done) { 33 | $done = time() >= $end; 34 | echo "Start new cron loop: ".(new DateTimeImmutable())->format('Y-m-d H:i:s.v')."..."; 35 | 36 | if($action == 'cacheToFile') { 37 | $result = $cron->cacheToFile(); 38 | } elseif ($action == 'fileToDatabase') { 39 | $result = $cron->fileToDatabase(); 40 | } 41 | 42 | echo $result->debug; 43 | if($result->inserts < 1000) { 44 | sleep(1); 45 | } 46 | } 47 | } elseif($action == 'geoipdownload') { 48 | echo $cron->geoIPDownload()."\n"; 49 | } elseif($action == 'toripdownload') { 50 | echo $cron->torIPDownload()."\n"; 51 | } elseif($action == 'geoipparse') { 52 | echo $cron->geoIPParse()."\n"; 53 | } elseif($action == 'toripparse') { 54 | echo $cron->torIPParse()."\n"; 55 | } 56 | 57 | // Updated locally cached settings and tables: settings, accounts and servers (do this at the end so cacheToFile can continue if the database is unreachable) 58 | if($updateCache) { 59 | echo $cron->updateLocalCache()->debug; 60 | } 61 | 62 | echo "End cron job: ".(new DateTime())->format('Y-m-d H:i:s.v')."\n"; 63 | -------------------------------------------------------------------------------- /txtlog/web/error.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | Page not found 5 |

6 |
7 |
8 | 9 | -------------------------------------------------------------------------------- /txtlog/web/faq.php: -------------------------------------------------------------------------------- 1 | get()->getSitename(); 6 | 7 | // Account info 8 | $account = (new Account)->get((new Settings)->get()->getAnonymousAccountID()); 9 | $maxRows = number_format($account->getMaxRows(), 0, ' ', '.'); 10 | $maxRowSize = $account->getMaxRowsize(); 11 | $maxIPUsage = number_format($account->getMaxIPUsage(), 0, ' ', '.'); 12 | $maxRetention = number_format($account->getMaxRetention(), 0, ' ', '.'); 13 | ?> 14 |
15 |
16 |
17 | What is this site? 18 |
19 | 20 |
21 | is a service for consolidating logs from various systems into one online log. It is used by many users worldwide to keep track of important events, such as server logins. 22 |
23 |
24 | 25 |
26 |
27 | License 28 |
29 | 30 |
31 | This service is licensed under the MIT license, see the LICENSE for further info. 32 |
33 | 34 |
35 | The Geo IP data is provided by iptoasn.com, licensed under the Public Domain Dedication and License version v1.0. 36 |
37 |
38 | 39 |
40 |
41 | Scripts 42 |
43 | 44 |
45 | The SSH and RDP scripts are simple and should work out of the box on most systems. They are built for resilience, i.e. when this site is slow or even offline, the regular login procedure is not affected. 46 |
47 |
48 | The RDP script might need some tweaking based on your Windows group policies. The script creates a new scheduled task, which should run automatically after an RDP event. If the script does not seem to work try one of these: 49 |
    50 |
  • Recommended way: Open the start menu -> Type cmd, right click on Command Prompt -> Run as administrator. In the command prompt run:
    51 | powershell C:\Temp\txtlog.ps1
    52 | Replace the path C:\Temp\txtlog.ps1 with the correct location of the script. Importing the task this way also prevent a Powershell window from flashing after you login. 53 |
  • 54 |
  • Open the created "txtlog" task in the Windows Task Scheduler. Change the Security options to Run whether user is logged in or not and change the User to a local account (such as your own account) instead of the Network Service. Save the task with OK, provide your username and password and try opening/closing an RDP session.
  • 55 |
56 |
57 |
58 | The SSH and RDP scripts are provided without any warranty. The scripts cannot access passwords. 59 |
60 |
61 | 62 |
63 |
64 | Uninstalling scripts 65 |
66 | 67 |
68 | The RDP script can be uninstalled by opening the Windows Task Scheduler and removing the task named txtlog. This completely removes the program without leaving any traces. 69 |
70 | 71 |
72 | The SSH script can be completely uninstalled by deleting the txtlog script and undoing the PAM modifications: 73 |
74 |
75 |
sudo rm -f /usr/local/bin/txtlog
 76 | sudo sed -i '/\(common auth info using \|\/usr\/local\/bin\/\)txtlog/d' /etc/pam.d/common-auth
77 |
78 |
79 | 80 |
81 |
82 | Payments 83 |
84 | 85 |
86 | is free to use, modify and host yourself, even for commercial use. See the MIT license for details.
87 | Upgrades are available offering more storage space for a fee. Payment is done securely using Stripe. A subscription can be cancelled at any time from the Stripe dashboard. 88 |
89 |
90 | 91 |
92 |
93 | Limits 94 |
95 | 96 |
97 | The following limits apply to free accounts, these can be changed when hosting yourself. 98 |
99 |
100 |
101 |
102 | Maximum rows per log 103 |
104 |
105 | 106 |
107 |
108 |
109 |
110 | Maximum length per row 111 |
112 |
113 | 114 |
115 |
116 |
117 |
118 | Maximum requests per 10 minutes 119 |
120 |
121 | 122 |
123 |
124 |
125 |
126 | Maximum retention 127 |
128 |
129 | days 130 |
131 |
132 |
133 |
134 |
135 | -------------------------------------------------------------------------------- /txtlog/web/index.php: -------------------------------------------------------------------------------- 1 | get()->getSitename(); 6 | $demoUrl = (new Settings)->get()->getDemoViewURL(); 7 | $demoDashboardUrl = (new Settings)->get()->getDemoDashboardURL(); 8 | $email = ''; 9 | 10 | try { 11 | $cache = new Cache(exitOnError: false); 12 | $email = (new Settings)->get()->getEmail() ?? ''; 13 | } catch(Exception $e) { 14 | // Cache not reachable 15 | } 16 | ?> 17 |
18 |
19 |
20 | Open source log 21 |
22 | 23 |
24 | is an easy to use text log 25 |
26 |
27 | Why another log service? 28 |
29 |
30 |
31 | Fault tolerant, store valid JSON, invalid JSON or raw text 32 |
33 |
34 | No required fields and a functional dashboard without distractions 35 |
36 |
37 | Automatic Geo IP checks for incoming logs and public dashboards 38 |
39 |
40 | Secure and high performance, with a queue based on Redis Streams 41 |
42 |
43 | All code is open source, released under the permissive MIT license 44 |
45 |
46 |
47 | 48 |
49 |
50 | How it looks 51 |
52 |
53 | 54 |
55 |
56 | 57 |
58 |
59 | Easy to use 60 |
61 |
62 |
63 | A unique link is automatically generated when you open this page. 64 |
65 |
66 | 67 |
68 |
69 |
70 |
71 |
72 |
73 | 76 |
77 |
78 | Account 79 |
80 | 81 |

82 | All functionality is available without an account but you can set an optional username and password instead of remembering the long URL.
83 |
84 |
85 |
86 |
87 | 88 |
89 |
90 |
91 |
92 | To further protect the log, you can remove the public view link.

93 | 94 |
95 |
96 |
97 |
98 | 99 | 0) { ?> 100 |
101 |
102 | Search 103 |
104 | 105 |

106 | The demo log contains fake data to test. 107 |
108 |
109 | Demo 110 |
111 | 0) { ?> 112 |

113 | Public dashboards have required search terms. For example, the next link only shows logs with at least the text POST and Safari in the log. 114 |
115 |
116 | Public dashboard 117 |
118 | 119 |
120 | 121 |
122 |
123 | Open source 124 |
125 | 126 |
127 | is licensed under the flexible MIT open source license.
128 | Built on trusted open source software by Willie Beek 129 |
130 |
131 |
132 | 133 | clickhouse 134 | 135 |
136 |
137 | 138 | mysql 139 | 140 |
141 |
142 | 143 | apache 144 | 145 |
146 |
147 | 148 | php 149 | 150 |
151 |
152 | 153 | jquery 154 | 155 |
156 |
157 | 158 | bulma 159 | 160 |
161 |
162 | 163 | redis 164 | 165 |
166 |
167 |
168 |
169 | -------------------------------------------------------------------------------- /txtlog/web/install.php: -------------------------------------------------------------------------------- 1 | isInstalled()) { 11 | header('Location: /'); 12 | exit; 13 | } 14 | 15 | // Install button pressed 16 | if(!empty(Common::post('submit'))) { 17 | $output = $installHelper->install(); 18 | echo '
'.implode("
\n", $output).'
'; 19 | exit; 20 | } 21 | 22 | // Required minimum PHP version 23 | $phpMinVersion = '8.3.0'; 24 | 25 | // The settings file, e.g. /var/www/site/web/../settings.php 26 | $settingsFile = $installHelper->getSettingsLocation(); 27 | 28 | // Directory for storing temporary data 29 | $tempDir = $installHelper->getTempDir(); 30 | 31 | ?> 32 |
33 |
34 |

35 | Installation 36 |

37 |
38 |
39 | 40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | getRow( 50 | "PHP version >= $phpMinVersion", 51 | version_compare(phpversion(), $phpMinVersion, '>='), 52 | "PHP version is too old, please upgrade to at least version $phpMinVersion"); 53 | ?> 54 | getRow( 55 | 'Redis available', 56 | extension_loaded('redis'), 57 | 'Enable Redis'); 58 | ?> 59 | getRow( 60 | 'Settings file writable', 61 | is_writable($settingsFile), 62 | 'Cannot update the settings file, set correct permissions, i.e. chmod 660 '.Common::getString($settingsFile)); 63 | ?> 64 | getRow( 65 | 'MySQL/MariaDB available', 66 | $installHelper->installedMySQL(), 67 | 'Enable the MySQL PDO driver'); 68 | ?> 69 | getRow( 70 | 'mbstring package', 71 | function_exists('mb_substr'), 72 | 'Install the mbstring package (apt install php-mbstring)'); 73 | ?> 74 | getRow( 75 | 'gmp package', 76 | function_exists('gmp_strval'), 77 | 'Install the gmp package (apt install php-gmp)'); 78 | ?> 79 | getRow( 80 | 'Curl', 81 | function_exists('curl_init'), 82 | 'Install the php curl package (apt install php-curl)'); 83 | ?> 84 | getRow( 85 | 'Browscap', 86 | ini_get('browscap'), 87 | 'Install "php_browscap.ini" from browscap.org'); 88 | ?> 89 | getRow( 90 | 'Temporary storage directory writable', 91 | is_writable($tempDir), 92 | 'Temporary directory is not writable, set correct permissions, i.e. chgrp www-data '.Common::getString($tempDir).' && chmod 770 '.Common::getString($tempDir)); 93 | ?> 94 | 95 |
CheckStatus
96 |
97 | 98 |
99 |
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | getInstallRow()?> 235 | 236 |
Server settings
Hostname
Redis host or unix socket filename
Redis port (ignored when using a socket)
MySQL settings
Database IP/hostname
Database name
Database port
Database username
Database password
Use SSL connection
Clickhouse settings
Database IP/hostname
Database name
Database port
Database username
Database password
Use SSL connection
Application settings
Public e-mail address shown on homepage
Admin status page user
Admin status page password
Maximum retention in days
Max API calls for creating, updating or deleting log metadata
Max select (GET) requests per 10 minutes
Max number of failed API calls per 10 minutes
Account settings
Query timeout for GET requests, in seconds
Max number of API requests per 10 minutes
Max number of rows per log
Max size per row in bytes
237 |
238 |
239 | -------------------------------------------------------------------------------- /txtlog/web/pricing.php: -------------------------------------------------------------------------------- 1 | get()->getSitename(); 11 | // Account info 12 | $free = (new Account)->get((new Settings)->get()->getAnonymousAccountID()); 13 | $settings = (new Settings)->get(); 14 | $pro1ID = $settings->getPro1AccountID(); 15 | $pro2ID = $settings->getPro2AccountID(); 16 | $paymentApiUrl = $settings->getPaymentApiUrl(); 17 | $paymentApiKey = $settings->getPaymentApiKey(); 18 | 19 | if($pro1ID < 1) { 20 | $GLOBALS['app']->error404(); 21 | } 22 | 23 | $cache = new Cache(); 24 | $msg = ''; 25 | $payment = new Payment(); 26 | $pro1 = (new Account)->get($pro1ID); 27 | $pro2 = (new Account)->get($pro2ID); 28 | $session = Common::get('session'); 29 | $txtlog = new Txtlog(); 30 | 31 | try { 32 | $cache->verifyIPFails(); 33 | } catch(Exception $e) { 34 | $msg = str_replace('_', ' ', $e->getMessage()); 35 | $session = null; 36 | } 37 | 38 | // After payment a session ID is set 39 | if(Common::canBeStripeSession($session)) { 40 | try { 41 | $cache->addIPUsageLog(); 42 | // Check if the payment already exists 43 | if($payment->get($session)) { 44 | throw new Exception('This payment has already been processed.'); 45 | } 46 | $paymentApiUrl .= $session; 47 | 48 | // Contact Stripe 49 | $ch = curl_init($paymentApiUrl); 50 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 51 | curl_setopt($ch, CURLOPT_USERPWD, "$paymentApiKey:"); 52 | 53 | $result = curl_exec($ch); 54 | $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); 55 | if($result === false || $status != 200) { 56 | throw new Exception("Invalid payment"); 57 | } 58 | 59 | curl_close($ch); 60 | $resultObj = json_decode($result); 61 | 62 | /* Use the userHash: https://docs.stripe.com/payment-links/url-parameters 63 | * client_reference_id can be composed of alphanumeric characters, dashes, or underscores, and be any value up to 200 characters. 64 | * Invalid values are silently dropped, but your payment page continues to work as expected. 65 | */ 66 | $userHash = $resultObj->client_reference_id ?? ''; 67 | $amount_subtotal = $resultObj->amount_subtotal ?? 0; 68 | 69 | if(strlen($userHash) < 1 || $amount_subtotal < 1) { 70 | throw new Exception('Invalid payment'); 71 | } 72 | 73 | // amount_subtotal: Total of all items before discounts or taxes are applied. 74 | foreach((new Account)->getAll() as $account) { 75 | if($account->getPrice() == $amount_subtotal) { 76 | $existingTxtlog = $txtlog->getByuserHash($userHash, useCache: false); 77 | if(empty($existingTxtlog->getID())) { 78 | throw new Exception('Log not found'); 79 | } 80 | 81 | $existingTxtlog->setAccountID($account->getID()); 82 | $existingTxtlog->setRetention($account->getMaxRetention()); 83 | $txtlog->update($existingTxtlog); 84 | $msg = 'Upgrade complete, thanks for your support!'; 85 | 86 | // Log the payment info 87 | $paymentInfo = new PaymentEntity(); 88 | $paymentInfo->setSessionID($session); 89 | $paymentInfo->setData($result); 90 | $payment->insert($paymentInfo); 91 | break; 92 | } 93 | } 94 | 95 | if(!$msg) { 96 | throw new Exception('Invalid payment'); 97 | } 98 | } catch(Exception $e) { 99 | $msg = $e->getMessage(); 100 | $cache->addIPFail(); 101 | } 102 | } 103 | ?> 104 | 105 | 106 | 107 |
108 |
109 |
110 | 111 |
112 | 113 |
114 | is free to use, modify and host yourself, even for commercial use! See the MIT license for details. 115 |
116 | 117 |
118 | A subscription helps to fund continued development and sustainability of this service. All payment processing is done with Stripe. 119 |
120 |
121 | 122 |
123 |
124 |
125 |
126 |
127 |

128 | Free
129 |   130 |

131 |
132 |
133 |
134 |
135 | Maximum logs: getMaxRows())?> 136 |
137 |
138 | Requests/10 min.: getMaxIPUsage())?> 139 |
140 |
141 | Retention: getMaxRetention()?> days 142 |
143 |
144 |
145 | 146 |
147 |
148 | 149 | 154 |
155 |
156 | 157 |
158 |
159 |
160 |

161 |  getName()?>
162 | $getPrice()/100, 2, '.', '')?> / month 163 |

164 |
165 | 166 |
167 |
168 |
169 | Maximum logs: getMaxRows())?> 170 |
171 |
172 | Requests/10 min.: getMaxIPUsage() == $pro1->getMaxRows() ? 'unlimited' : Common::formatReadable($pro1->getMaxIPUsage()))?> 173 |
174 |
175 | Retention: getMaxRetention()?> days 176 |
177 |
178 |
179 | 180 |
181 |
182 |
183 |
184 | 185 |
186 |
187 |
188 |

189 | 190 |

191 |
192 |
193 |
194 |
195 |
196 | 197 | 202 |
203 |
204 | 205 |
206 |
207 |
208 |

209 |  getName()?>
210 | $getPrice()/100, 2, '.', '')?> / month 211 |

212 |
213 | 214 |
215 |
216 |
217 | Maximum logs: getMaxRows())?> 218 |
219 |
220 | Requests/10 min.: getMaxIPUsage() == $pro2->getMaxRows() ? 'unlimited' : Common::formatReadable($pro2->getMaxIPUsage()))?> 221 |
222 |
223 | Retention: getMaxRetention()?> days 224 |
225 |
226 |
227 | 228 |
229 |
230 |
231 |
232 | 233 |
234 |
235 |
236 |

237 | 238 |

239 |
240 |
241 |
242 |
243 |
244 | 245 | 250 |
251 |
252 |
253 |
254 |
255 | -------------------------------------------------------------------------------- /txtlog/web/privacy.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Privacy policy 5 |
6 | 7 |
8 | This service is used to store and analyze user activity logs. 9 |
10 | 11 |
12 | When you connect to this website your IP address and user agent will be logged in the server logs. This data is automatically deleted using the logrotate program, which is usually after 14 days. 13 |
14 | 15 |
16 | When you post data to a log, the data is stored in a database. The data is deleted according to the retention policy. To check the retention get the logfile using the API. When you delete a log through the API the link to the log is lost but the log rows are not removed immediately but according to the log retention policy. 17 |
18 | 19 |
20 | All data is stored on multiple servers and is not shared/sold or otherwise distributed, unless required by a court of law. We are not responsible for loss of data. 21 |
22 | 23 |
24 | This website does not use tracking cookies or other forms of tracking. 25 |
26 | 27 |
28 |
29 |
30 | Changelog 31 |
32 |
33 |
34 |
35 | 11 Feb 2024 36 |
37 |
38 | Explained log rotation 39 |
40 |
41 |
42 |
43 | 02 Jan 2021 44 |
45 |
46 | First version 47 |
48 |
49 |
50 |
51 |
52 | -------------------------------------------------------------------------------- /txtlog/web/selfhost.php: -------------------------------------------------------------------------------- 1 | get()->getSitename(); 5 | ?> 6 |
7 |
8 |
9 | Self hosting 10 |
11 | 12 |
13 | Setting up a self hosted instance is a great way to keep data under your own control. Installing a new environment requires some Unix/Linux knowledge and one or more servers. There are several types of servers required, when starting small it's possible to combine multiple servers on one machine, e.g. host MySQL and Apache on the same server. The following instructions are tested on the most recent Ubuntu LTS, but installation is possible on most Linux distributions. 14 |
15 | 16 |
17 | Requirements 18 |
19 | 20 |
21 |
22 |
23 |
24 |
25 | Linux server(s) with SSH access 26 |
27 |
28 |
29 |
30 | Webserver with at least one CPU core, 512 MB memory and 1GB disk 31 |
32 |
33 |
34 |
35 | Database server with at least one CPU core, 512 MB memory and 1GB disk 36 |
37 |
38 |
39 |
40 | ClickHouse server or ClickHouse cloud with at least 8GB RAM 41 |
42 |
43 |
44 |
45 | A domain name and DNS provider 46 |
47 |
48 |
49 |
50 | Moderate Linux knowledge 51 |
52 |
53 |
54 |
55 |
56 | 57 |
58 | Installation 59 |
60 | 61 |
62 |
63 |

Webserver

64 |
65 |
66 |
The first step is installing one or more webservers. Each webserver works autonomously so you can add and remove servers when needed. These instructions use Apache with FPM (FastCGI Process Manager) but nginx works just as well. Install Apache, PHP, Redis and the required PHP modules. Consult the Redis documentation to enable some form of persistency when possible.
67 | apt install apache2 libapache2-mod-fcgid curl php-fpm redis php-redis php-mbstring php-curl php-mysql php-gmp 68 |
69 |
70 | 71 |
72 |
73 |

MySQL/MariaDB

74 |
75 |
76 |
MySQL is used to store the settings and metadata for the logs. Both MySQL and MariaDB are tested and should work equally well, mysql-client is optional but useful for testing.
77 | apt install mysql-server mysql-client 78 |
Tip: secure the SQL installation.
79 | mysql_secure_installation 80 |
It's recommended to create a user account with limited privileges to connect to MySQL. Login to MySQL as root and create a "txtlog" user, replace p@ssw0rd with a secure password.
81 | CREATE DATABASE txtlog COLLATE 'utf8mb4_unicode_ci';
82 | CREATE USER IF NOT EXISTS 'txtlog'@'localhost' IDENTIFIED BY 'p@ssw0rd';
83 | GRANT ALL ON txtlog.* TO 'txtlog'@'localhost';
84 |
85 |
86 | 87 |
88 |
89 |

ClickHouse

90 |
91 |
92 |
ClickHouse handles the heavy lifting of processing billions of log entries. Instead of self hosting ClickHouse server, using the official ClickHouse cloud should also work. For self hosting, add the repository using the ClickHouse installation instruction and install ClickHouse server.
93 | apt install clickhouse-server clickhouse-client 94 |
Configuring ClickHouse is similar to MySQL. Again, create a user to connect to ClickHouse. Login to ClickHouse with the default user and create a "txtlog" user, replace p@ssw0rd with a secure password.
95 | CREATE DATABASE txtlog
96 | CREATE USER IF NOT EXISTS txtlog IDENTIFIED WITH sha256_password BY 'p@ssw0rd'
97 | GRANT ALL ON txtlog.* TO txtlog 98 |
For the admin page to work grant the following additional privileges as the ClickHouse administrator.
99 | GRANT SELECT ON system.query_log TO txtlog
100 | GRANT SELECT ON system.parts TO txtlog
101 | GRANT SELECT ON system.parts_columns TO txtlog
102 | GRANT SELECT ON system.data_skipping_indices TO txtlog
103 | GRANT SELECT ON system.disks TO txtlog 104 |
Scaling to more servers can be done using ClickHouse Keeper or by adding more ClickHouse servers and adding the IP and credentials to the Server table on the MySQL server.
105 |
106 |
107 | 108 |
109 |
110 |

Cron jobs

111 |
112 |
113 |
Log rows are processed in batches using a fast in memory queue (Redis Streams). On each webserver, set at least two cron jobs to handle updating the cache, converting Redis memory streams to disk and to update the ClickHouse database.
114 |
Create a cron job to update the cached settings and convert Redis streams (which contain the log lines) to files.
115 | curl '/cron?updatecache=true&action=cachetofile'
116 |
Create another cron job to insert the rows into the ClickHouse database.
117 | curl '/cron?action=filetodatabase'
118 |
To combine these, the following code removes the existing cron job for the current user (!), creates the required jobs and runs them every minute.
119 | crontab -r
120 | (
121 | echo "* * * * * curl -s '/cron?updatecache=true&action=cachetofile' >>/tmp/txtlog.cron 2>&1" &&
122 | echo "* * * * * curl -s '/cron?action=filetodatabase' >>/tmp/txtlog.cron 2>&1"
123 | ) | crontab -
124 |
To use the Geo IP and Tor IP functions, run these commands.
125 | curl '/cron?action=geoipdownload'
126 | curl '/cron?action=geoipparse'
127 | curl '/cron?action=toripdownload'
128 | curl '/cron?action=toripparse'
129 |
130 |
131 | 132 |
133 |
134 |

Application installation

135 |
136 |
137 |
Clone the latest version from the GitHub repository, move it to the website directory (e.g. /var/www) on the webserver. Start the installation process, which will guide you through the settings. The installer will automatically disable after the installation is complete. When in doubt about a setting, keep the defaults shown on screen.
138 | The installer will run dependency checks before the installation can proceed.
139 | 140 |
141 | Installation 142 |
143 |
144 |
145 | 146 |
147 |
148 |

Administration

149 |
150 |
151 |
After the installation is complete, the installer will be disabled. You can use the admin page to get a quick system overview.
152 |
153 | Admin page 154 |
155 |
156 |
157 |
158 |
159 | -------------------------------------------------------------------------------- /txtlog/web/txtlog.php: -------------------------------------------------------------------------------- 1 | get()->getLogDomain(); 8 | 9 | if(strlen($auth) > 100 || !in_array($type, ['ssh', 'rdp'])) { 10 | exit; 11 | } 12 | 13 | if($type == 'ssh') { 14 | $file = $_SERVER['DOCUMENT_ROOT'].'/../txtlog/scripts/txtlog'; 15 | $contents = file_get_contents($file); 16 | $contents = str_replace('$REPLACE_AUTH_CODE', $auth, $contents); 17 | $contents = str_replace('$REPLACE_DOMAIN', $logdomain, $contents); 18 | } elseif($type == 'rdp') { 19 | $file = $_SERVER['DOCUMENT_ROOT'].'/../txtlog/scripts/txtlog.ps1'; 20 | $contents = file_get_contents($file); 21 | 22 | $charset = 'UTF-16LE'; 23 | mb_regex_encoding($charset); 24 | // Use multibyte replace for non ASCII scripts 25 | $contents = mb_ereg_replace(mb_convert_encoding('\$REPLACE_AUTH_CODE', $charset, 'UTF-8'), mb_convert_encoding($auth, $charset, 'UTF-8') , $contents); 26 | $contents = mb_ereg_replace(mb_convert_encoding('\$REPLACE_DOMAIN', $charset, 'UTF-8'), mb_convert_encoding($logdomain, $charset, 'UTF-8') , $contents); 27 | } else { 28 | exit; 29 | } 30 | 31 | $filename = basename($file); 32 | 33 | header('Content-Type: application/octet-stream'); 34 | header("Content-Disposition: attachment; filename=\"$filename\""); 35 | header('Expires: 0'); 36 | header('Cache-Control: must-revalidate'); 37 | header('Pragma: public'); 38 | header('Content-Length: '.strlen($contents)); 39 | 40 | echo $contents; 41 | -------------------------------------------------------------------------------- /web/.htaccess: -------------------------------------------------------------------------------- 1 | # Rewrite all requests to index.php. 2 | rewriteEngine On 3 | 4 | # First check if there is an offline file, indicating the site should be offline 5 | RewriteCond %{DOCUMENT_ROOT}/app_offline.php -f 6 | RewriteRule ^(.*) %{DOCUMENT_ROOT}/app_offline.php [NC,L] 7 | 8 | # Based on Joomla single entry point logic 9 | # If the request is something other than index.php 10 | RewriteCond %{REQUEST_URI} !^/index\.php 11 | # And the requested filename doesn't exist 12 | RewriteCond %{REQUEST_FILENAME} !-f 13 | # And the requested directory does not exist 14 | RewriteCond %{REQUEST_FILENAME} !-d 15 | # Then rewrite the request to the index.php script 16 | RewriteRule (.*) index.php [L] 17 | -------------------------------------------------------------------------------- /web/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 WillieBeek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/css/main.css: -------------------------------------------------------------------------------- 1 | .navcustom { 2 | padding: 1rem 2rem; 3 | border-bottom: 2px solid #f5f5f5; 4 | } 5 | .notop { 6 | margin-top: -3rem; 7 | } 8 | .example { 9 | white-space: pre-line; 10 | word-break: break-all; 11 | } 12 | .logline { 13 | white-space: pre-wrap; 14 | word-break: break-all; 15 | } 16 | .future-result,.example,.hide { 17 | display: none; 18 | } 19 | .narrow { 20 | max-width: 600px; 21 | } 22 | .medium { 23 | max-width: 700px; 24 | min-height: 90px; 25 | } 26 | .dashboard { 27 | font-size: .85rem; 28 | max-width: 1250px; 29 | display: none; 30 | } 31 | @media only screen and (max-width: 600px) { 32 | .mobwide { 33 | padding-right: 0; 34 | } 35 | /* Prevent scrolling tabs on mobile */ 36 | .tabs li { 37 | margin-left: -.4rem; 38 | } 39 | } 40 | .doc { 41 | background-color: white; 42 | padding: .5rem .2rem; 43 | white-space: pre-wrap; 44 | } 45 | .high { 46 | min-height: 220px; 47 | } 48 | .cmd { 49 | max-width: 30rem; 50 | } 51 | .is-right { 52 | text-align: right; 53 | } 54 | .striped { 55 | background-color: var(--bulma-code-background); 56 | } 57 | .logcode { 58 | width: 12rem; 59 | } 60 | 61 | /* JSON */ 62 | .json_string { 63 | color: #dd00a9; 64 | } 65 | .json_number { 66 | color: darkorange; 67 | } 68 | .json_boolean { 69 | color: darkorange; 70 | } 71 | .json_null { 72 | color: magenta; 73 | } 74 | .json_key { 75 | color: #127eea; 76 | } 77 | 78 | /* Sticky footer */ 79 | html, body { 80 | min-height: 100vh; 81 | } 82 | .footer { 83 | position: sticky; 84 | top: 100vh; 85 | } 86 | -------------------------------------------------------------------------------- /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/favicon.ico -------------------------------------------------------------------------------- /web/images/admin.interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/admin.interface.png -------------------------------------------------------------------------------- /web/images/apache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/apache.png -------------------------------------------------------------------------------- /web/images/bulma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/bulma.png -------------------------------------------------------------------------------- /web/images/clickhouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/clickhouse.png -------------------------------------------------------------------------------- /web/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/github.png -------------------------------------------------------------------------------- /web/images/installation.checks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/installation.checks.png -------------------------------------------------------------------------------- /web/images/jquery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/jquery.png -------------------------------------------------------------------------------- /web/images/lightdark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/lightdark.png -------------------------------------------------------------------------------- /web/images/mysql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/mysql.png -------------------------------------------------------------------------------- /web/images/php.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/php.png -------------------------------------------------------------------------------- /web/images/rdp.txtlog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/rdp.txtlog.png -------------------------------------------------------------------------------- /web/images/redis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/redis.png -------------------------------------------------------------------------------- /web/images/txtlog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillieBeek/txtlog/6851dafb963cdabda7d6bfa22da09c0f98b2d6f9/web/images/txtlog.jpg -------------------------------------------------------------------------------- /web/index.php: -------------------------------------------------------------------------------- 1 | start(); 10 | 11 | // Include the application specific master page, if it exists 12 | $app->showHeader(); 13 | 14 | // Include the requested page if it is found 15 | if($app->getPage()) { 16 | require $app->getPage(); 17 | } else { 18 | $app->error404(); 19 | } 20 | 21 | // Include the application specific footer, if it exists 22 | $app->showFooter(); 23 | -------------------------------------------------------------------------------- /web/scripts/main.js: -------------------------------------------------------------------------------- 1 | class Txtlog { 2 | apiurl = '/api/log'; 3 | inurl = $('#logdomain').val() + '/api/log'; 4 | loginurl = '/api/login'; 5 | tokenurl = '/api/token/'; 6 | 7 | constructor() { 8 | this.parseCookie(); 9 | this.setUrl(this.logurls); 10 | } 11 | 12 | // https://stackoverflow.com/questions/4810841/how-can-i-pretty-print-json-using-javascript 13 | syntaxHighlight(str) { 14 | try { 15 | JSON.parse(str); 16 | } catch(e) { 17 | return str; 18 | } 19 | 20 | str = str.replace(/ /g, ' '); 21 | str = str.replace(/&/g, '&').replace(//g, '>'); 22 | str = str.replace(/\\n/g, "\n"); 23 | return str.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { 24 | var cls = 'number'; 25 | if(/^"/.test(match)) { 26 | if(/:$/.test(match)) { 27 | cls = 'key'; 28 | } else { 29 | cls = 'string'; 30 | } 31 | } else if(/true|false/.test(match)) { 32 | cls = 'boolean'; 33 | } else if (/null/.test(match)) { 34 | cls = 'null'; 35 | } 36 | 37 | return '' + match + ''; 38 | }); 39 | } 40 | 41 | parseCookie() { 42 | try { 43 | this.logurls = JSON.parse(document.cookie.split(";").find((row) => row.startsWith("logurls="))?.split("=")[1]); 44 | } catch(e) { 45 | } 46 | } 47 | 48 | setCookie(data) { 49 | let month = 60*60*24*30; 50 | document.cookie = 'logurls=' + JSON.stringify(data) + ';max-age=' + month + ';SameSite=Strict;Secure;Path=/'; 51 | this.logurls = data; 52 | this.setUrl(data); 53 | } 54 | 55 | logout() { 56 | document.cookie = 'logurls=;expires=0;SameSite=Strict;Secure;Path=/'; 57 | this.logurls = undefined; 58 | $('#logoutmsg,#loginform').show(); 59 | $('.logininfo').hide(); 60 | } 61 | 62 | generateNewLog() { 63 | this.logout(); 64 | let that = this; 65 | 66 | $.post(this.apiurl, function(data) { 67 | that.logurls = data; 68 | that.setCookie(data) 69 | }); 70 | } 71 | 72 | setUrl(data) { 73 | if(data === undefined) { 74 | return; 75 | } 76 | let url = data.view || this.apiurl; 77 | let insert = data.insert === undefined ? '' : data.insert.split('/').pop(); 78 | let viewcode = url === undefined ? '' : url.split('/').pop(); 79 | 80 | $('.logid').attr('href', url); 81 | $('.logurl').text(url); 82 | $('#linuxapp').attr('href', '/txtlog?type=ssh&auth=' + insert); 83 | $('#rdpapp').attr('href', '/txtlog?type=rdp&auth=' + insert); 84 | $('.requireslog').prop('disabled', false); 85 | 86 | $('.logcode').val(viewcode); 87 | this.setUpgradeUrl(data); 88 | this.parseCookie(); 89 | } 90 | 91 | setUpgradeUrl(data) { 92 | if(data.username === undefined) { 93 | $('.upgradebutton').text('Set a username first'); 94 | return; 95 | } 96 | 97 | $('.username').val(data.username); 98 | $('.upgradeurl1').attr('href', $('#upgradebase1').val() + '?client_reference_id=' + data.userhash); 99 | $('.upgradeurl2').attr('href', $('#upgradebase2').val() + '?client_reference_id=' + data.userhash); 100 | $('.upgradeurl1 button,.upgradeurl2 button').prop('disabled', false); 101 | } 102 | 103 | setLoggedInText() { 104 | $('.logininfo').show(); 105 | $('#loggedinuser').text(this.logurls.username); 106 | $('#loggedinaccount').text(this.logurls.account); 107 | $('#loggedinretention').text(this.logurls.retention); 108 | } 109 | 110 | login() { 111 | let username = $('#username').val(); 112 | let password = $('#password').val(); 113 | let that = this; 114 | this.logout(); 115 | 116 | $.post(this.loginurl, { username: username, password: password }, function(data) { 117 | that.setCookie(data); 118 | that.setLoggedInText(); 119 | $('#loginform,#logoutmsg').hide(); 120 | // Show remove public view URL button 121 | if(data.view !== undefined) { 122 | $('.accessdenied').hide(); 123 | $('#removeview').show(); 124 | } 125 | }).fail(function(xhr, status, error) { 126 | $('.accessdenied').show(); 127 | }); 128 | } 129 | 130 | addLog() { 131 | let logdata = $('#logdata').val(); 132 | let token = this.logurls.insert; 133 | let that = this; 134 | 135 | $.ajax({ 136 | url: this.inurl, 137 | type: 'POST', 138 | data: logdata, 139 | headers: { 'Authorization': token }, 140 | processData: false, 141 | success: function(result) { 142 | // Parse JSON for readability 143 | let str = JSON.stringify(result, null, 4); 144 | str = that.syntaxHighlight(str); 145 | 146 | $('#addlog-result').show().html(str); 147 | $('#addlog-curl').show().html('curl ' + that.inurl + ' \\
-H "Authorization: ' + token + '" \\
-d \'' + logdata.trim() + "'"); 148 | }, 149 | error: function(xhr, status, error) { 150 | $('#addlog-result').show().html(xhr.status + ': ' + xhr.statusText); 151 | } 152 | }); 153 | } 154 | 155 | protect() { 156 | let username = $('#username').val(); 157 | let password = $('#password').val(); 158 | let that = this; 159 | 160 | if(username.length < 1 || password.length < 1) { 161 | return; 162 | } 163 | if(this.logurls.username !== undefined && this.logurls.username !== username) { 164 | $('#protectresult').text('Invalid username'); 165 | return; 166 | } 167 | 168 | $.ajax({ 169 | url: this.apiurl, 170 | type: 'PATCH', 171 | data: { username: username, password: password }, 172 | headers: { 'Authorization': this.logurls.admin }, 173 | success: function(result) { 174 | $('#protectresult').text(result.usermsg); 175 | that.login(); 176 | }, 177 | error: function(result) { 178 | $('#protectresult').text(result.responseJSON.error); 179 | } 180 | }); 181 | } 182 | 183 | removeViewToken() { 184 | let viewtoken = this.logurls.view.split('/').pop(); 185 | let that = this; 186 | 187 | $.ajax({ 188 | url: this.tokenurl, 189 | type: 'DELETE', 190 | data: { token: viewtoken }, 191 | headers: { 'Authorization': this.logurls.admin }, 192 | success: function(result) { 193 | $('#removeviewresult').text(result.detail); 194 | // Update cookie 195 | that.login(); 196 | }, 197 | error: function(result) { 198 | $('#removeviewresult').text(result.responseJSON.error); 199 | } 200 | }); 201 | } 202 | } 203 | 204 | $(document).ready(function() { 205 | const txtlog = new Txtlog(); 206 | 207 | // Set light or dark mode 208 | $('html').attr('data-theme', localStorage.getItem('theme')); 209 | 210 | if(txtlog.logurls === undefined) { 211 | txtlog.generateNewLog(); 212 | } 213 | 214 | $(document).on('click', '#generatenew', function() { 215 | txtlog.generateNewLog(); 216 | $('#generatenewinfo').show(); 217 | }); 218 | 219 | // Add log row 220 | $(document).on('click', '#addlog', function() { 221 | txtlog.addLog(); 222 | }); 223 | 224 | // Add color to the dashboard 225 | $('.logline').each(function() { 226 | str = txtlog.syntaxHighlight($(this).text()); 227 | 228 | // Flatten contents by removing starting and ending { } tags 229 | str = str.replace(/^{\n/, '').replace(/\n}$/, ''); 230 | 231 | // Reduce indentation 232 | str = str.replace(/^ /gm, ''); 233 | 234 | $(this).html(str); 235 | }); 236 | $('.dashboard,.footer').show(); 237 | 238 | // Documentation tabs 239 | $(document).on('click', '.examples ul li', function() { 240 | let lang = $(this).data('example'); 241 | $('.example').hide(); 242 | $('.examples ul li').removeClass('is-active'); 243 | $('.' + lang + '-example').show(); 244 | $('.tab' + lang).addClass('is-active'); 245 | }); 246 | 247 | // Default to cURL documentation 248 | $('.curl-example').show(); 249 | 250 | // Protect a log 251 | $(document).on('keyup', '#username,#password', function(e) { 252 | if(e.which == 13) { 253 | $('#protect').trigger('click'); 254 | } 255 | }); 256 | $(document).on('click', '#protect', function() { 257 | txtlog.protect(); 258 | }); 259 | $(document).on('click', '#removeviewbutton', function() { 260 | txtlog.removeViewToken(); 261 | }); 262 | 263 | // Login 264 | if(txtlog.logurls !== undefined && txtlog.logurls.username !== undefined && txtlog.logurls.username.length > 0) { 265 | txtlog.setLoggedInText(); 266 | } else { 267 | $('#loginform').show(); 268 | } 269 | 270 | $('#login').submit(function(e) { 271 | e.preventDefault(); 272 | txtlog.login(); 273 | }); 274 | 275 | // Logout 276 | $(document).on('click', '#logout', function() { 277 | txtlog.logout(); 278 | }); 279 | 280 | // Toggle between light and dark mode 281 | $(document).on('click', '#dark', function() { 282 | let newTheme = $('html').attr('data-theme') == 'dark' ? 'light' : 'dark'; 283 | $('html').attr('data-theme', newTheme); 284 | localStorage.setItem('theme', newTheme); 285 | }); 286 | }); 287 | --------------------------------------------------------------------------------