tiid.tcl (19015B)
1 #!/usr/bin/env tclsh 2 # tiid: multiprotocol tii node daemon 3 # can work both over HTTP and Gopher/Nex 4 # Usage: tiid.tcl [port] [nodename] [dbfile] 5 # default port is 8080, default dbfile is tii.db 6 # Depends upon Tcllib and sqlite3 7 # Created by Luxferre in 2024, released into public domain 8 9 package require sqlite3 10 package require sha256 11 12 set scriptpath [file normalize [info script]] 13 set appdir [file dirname $scriptpath] 14 # check if we're running from a starpack 15 if [string match *app-tiid $appdir] { 16 set appdir [file normalize [file join $appdir ".." ".." ".." ]] 17 } 18 set localdb [file join $appdir "tii.db"] 19 set listenport 8080 20 21 # node name, used for the originating message addresses 22 set nodename "tiid" 23 24 # ensure database file is created 25 proc createdb {fname} { 26 sqlite3 fdb $fname 27 fdb eval { 28 PRAGMA journal_mode=WAL; 29 CREATE TABLE `msg` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, 30 `msgid` VARCHAR(20) NOT NULL UNIQUE, 31 `timestamp` INT NOT NULL, 32 `echoname` VARCHAR(120) NOT NULL, 33 `repto` VARCHAR(120) NOT NULL, 34 `msgfrom` VARCHAR(120) NOT NULL, 35 `msgfromaddr` VARCHAR(120) NOT NULL, 36 `msgto` VARCHAR(120) NOT NULL, 37 `subj` VARCHAR(120) NOT NULL, 38 `body` TEXT NOT NULL, 39 `blacklisted` BOOLEAN NOT NULL DEFAULT 0, 40 `content_id` VARCHAR(20) NOT NULL 41 CHECK (`blacklisted` IN (0, 1))); 42 CREATE TABLE `echo` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, 43 `name` VARCHAR(120) NOT NULL UNIQUE, 44 `description` VARCHAR(500)); 45 CREATE TABLE `auth` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, 46 `username` VARCHAR(64) NOT NULL UNIQUE, 47 `authstrhash` VARCHAR(64) NOT NULL, 48 `posting_acl` VARCHAR(1024)); 49 } 50 fdb close 51 } 52 53 # node logic here 54 55 # echo name validity check 56 proc validecho {str} { 57 set len [string length $str] 58 set validator {^[a-z0-9\-_]+\.[a-z0-9\-_\.]+$} 59 return [expr {$len > 2 && $len < 121 && [regexp $validator $str]}] 60 } 61 62 # message ID validity check 63 proc validmsgid {str} { 64 set validator {^[a-zA-Z0-9]+$} 65 return [expr {[string length $str] == 20 && [regexp $validator $str]}] 66 } 67 68 # url component decoder 69 proc decurl {string} { 70 set mapped [string map {+ { } \[ "\\\[" \] "\\\]" $ "\\$" \\ "\\\\"} $string] 71 encoding convertfrom utf-8 [subst [regsub -all {%([[:xdigit:]]{2})} $string {[format %c 0x\1]}]] 72 } 73 74 # parse query parameters into a dict 75 proc qparams {url args} { 76 set dict [list] 77 foreach x [split [lindex [split $url ?] 1] &] { 78 set x [split $x =] 79 if {[llength $x] < 2} { lappend x "" } 80 lappend dict {*}$x 81 } 82 if {[llength $args] > 0} { 83 return [dict get $dict [lindex $args 0]] 84 } 85 return $dict 86 } 87 88 # /list.txt handler 89 proc listechos {dbfile} { 90 sqlite3 db $dbfile -readonly true 91 set res [db eval { 92 SELECT CONCAT(`echo`.`name`, ':', COUNT(`msg`.`id`), ':', `echo`.`description`) FROM `echo` 93 LEFT JOIN `msg` ON `msg`.`echoname` = `echo`.`name` WHERE `msg`.`blacklisted` = 0 94 GROUP BY `msg`.`echoname` ORDER BY `echo`.`name`; 95 }] 96 db close 97 return "[string trim [encoding convertto utf-8 [join $res \n]]]\n" 98 } 99 100 # /blacklist.txt handler 101 proc blacklisted {dbfile} { 102 sqlite3 db $dbfile -readonly true 103 set res [db eval {SELECT `msgid` FROM `msg` WHERE `blacklisted` = 1 ORDER BY `id` ASC;}] 104 db close 105 return "[string trim [join $res \n]]\n" 106 } 107 108 # /m handler 109 proc singlemsg {dbfile msgid} { 110 set mdata {} 111 sqlite3 db $dbfile -readonly true 112 db eval {SELECT * from `msg` WHERE `msgid` = :msgid} msg { 113 append mdata {ii/ok} 114 if {$msg(repto) ne {}} {append mdata "/repto/$msg(repto)"} 115 append mdata "\n$msg(echoname)\n$msg(timestamp)" 116 append mdata "\n$msg(msgfrom)\n$msg(msgfromaddr)" 117 append mdata "\n$msg(msgto)\n$msg(subj)\n\n[join $msg(body) \n]" 118 } 119 db close 120 return "[string trim [encoding convertto utf-8 $mdata]]\n" 121 } 122 123 # /u/m handler 124 proc multimsg {dbfile idlist} { 125 set mdata {} 126 set query {SELECT * from `msg` WHERE `msgid` IN (} 127 append query [join [lmap s $idlist {string cat ' $s '}] ,] {) ORDER BY `id` ASC;} 128 sqlite3 db $dbfile -readonly true 129 db eval $query msg { 130 set mform {ii/ok} 131 if {$msg(repto) ne {}} {append mform "/repto/$msg(repto)"} 132 append mform "\n$msg(echoname)\n$msg(timestamp)" 133 append mform "\n$msg(msgfrom)\n$msg(msgfromaddr)" 134 append mform "\n$msg(msgto)\n$msg(subj)\n\n[join $msg(body) \n]" 135 append mdata $msg(msgid) ":" [binary encode base64 [encoding convertto utf-8 $mform]] \n 136 } 137 db close 138 return $mdata 139 } 140 141 # echo indexer for /e and /u/e 142 proc indexechos {dbfile echolist includenames offset limit} { 143 set rdata {} 144 set oquery {ORDER BY `id`} 145 if {$limit > 0} { # trigger limiting logic only with positive limit value 146 if {$offset >= 0} { # normal limiting flow 147 append oquery " ASC LIMIT $offset,$limit" 148 } else { 149 set reallimit [expr {-$offset}] 150 set realoffset [expr {$reallimit - $limit}] 151 if {$realoffset >= 0} { 152 append oquery " DESC LIMIT $realoffset,$reallimit" 153 } else { # invalid limit, falling back to full query 154 append oquery " ASC" 155 } 156 } 157 } 158 set query {SELECT CONCAT(`echoname`, ':', GROUP_CONCAT(`msgid`,'|' ORDER BY `id`)) AS `rowcat` FROM (} 159 foreach echo $echolist { 160 append query "SELECT * FROM (SELECT `id`, `msgid`, `echoname` FROM `msg` WHERE `echoname` = '$echo' $oquery) UNION ALL " 161 } 162 append query {SELECT NULL,NULL,NULL) GROUP BY `echoname` ORDER BY `echoname` ASC;} 163 sqlite3 db $dbfile -readonly true 164 db eval $query echorow { 165 if {$echorow(rowcat) ne ""} { 166 set eparts [split $echorow(rowcat) :] 167 set ename [lindex $eparts 0] 168 if {$ename ne ""} { 169 if {$includenames > 0} { 170 append rdata $ename \n 171 } 172 append rdata [join [split [lindex $eparts 1] "|"] \n] \n 173 } 174 } 175 } 176 db close 177 return $rdata 178 } 179 180 # /u/point handler 181 proc postmsg {dbfile authstr body} { 182 global nodename 183 set msgfrom "" 184 set acl "" 185 set authhash [::sha2::sha256 -hex -- [string trim $authstr]] 186 sqlite3 db $dbfile -readonly true 187 db eval {SELECT `id`, `username`, `posting_acl` FROM `auth` WHERE `authstrhash` = :authhash} user { 188 set msgfrom $user(username) 189 set msgfromaddr "$nodename,$user(id)" 190 set acl [string trim $user(posting_acl)] 191 } 192 db close 193 194 if {$msgfrom ne ""} { 195 # auth successful, process the body 196 set p2nmsg [split [encoding convertfrom utf-8 [binary decode base64 $body]] "\n"] 197 if {[llength $p2nmsg] > 4} { 198 set echoname [string trim [lindex $p2nmsg 0]] 199 if {$acl ne "*"} { # check if the user can post in the echo 200 set posting_allowed 0 201 set acl [split $acl ,] 202 foreach acl_echo $acl { 203 if {$echoname eq $acl_echo} { 204 set posting_allowed 1 205 break 206 } 207 } 208 if {posting_allowed eq 0} { 209 return "posting to this echo is not allowed for this user" 210 } 211 } 212 set msgto [string trim [lindex $p2nmsg 1]] 213 set subj [string trim [lindex $p2nmsg 2]] 214 set line4 [string trim [lindex $p2nmsg 4]] 215 if {[string match @repto:* $line4]} { 216 set repto [string range $line4 7 end] 217 if {![validmsgid $repto]} {return "invalid repto message ID"} 218 set msgbody [join [lrange $p2nmsg 5 end] "\n"] 219 } else { 220 set repto "" 221 set msgbody [join [lrange $p2nmsg 4 end] "\n"] 222 } 223 set timestamp [clock seconds] 224 set mform {ii/ok} 225 if {$repto ne {}} {append mform "/repto/$repto"} 226 append mform "\n$echoname\n$timestamp" 227 append mform "\n$msgfrom\n$msgfromaddr" 228 append mform "\nmsgto\n$subj\n\n$msgbody" 229 # generate the message ID 230 set hash [::sha2::sha256 -bin -- $mform] 231 set trimbased [string range [binary encode base64 $hash] 0 19] 232 set msgid [string map {+ A - A / z _ z} $trimbased] 233 # perform the insertion 234 set msgbody [split $msgbody "\n"] 235 sqlite3 db $dbfile 236 db eval {INSERT OR IGNORE INTO `msg` (`msgid`, `timestamp`, `echoname`, `repto`, 237 `msgfrom`, `msgfromaddr`, `msgto`, `subj`, `body`, `blacklisted`, `content_id`) 238 VALUES (:msgid, :timestamp, :echoname, :repto, :msgfrom, :msgfromaddr, :msgto, 239 :subj, :msgbody , 0, :msgid);} 240 db close 241 return "msg ok" 242 } else {return "invalid message structure"} 243 } else {return "no auth"} 244 } 245 246 # /u/push handler 247 proc postbundle {dbfile authstr body} { 248 return "push logic is not implemented" 249 } 250 251 # / handler (index page) 252 proc indexpage {} { 253 global nodename 254 return [string cat "status: ready\nserver: tiid\nnodename: $nodename\n" \ 255 {apis: /list.txt /blacklist.txt /e /m /u/e /u/m /u/point} \n] 256 } 257 258 # TCP logic here 259 260 # compression function 261 proc repcompress {compfunc data} { 262 if {$compfunc eq {gzip}} { 263 return [zlib gzip $data -level 9] 264 } else { 265 return [zlib $compfunc $data 9] 266 } 267 } 268 269 # error report/reply 270 proc reperror {sock ishttp errmsg compfunc} { 271 set errmsg "error: $errmsg\n" 272 if {$ishttp eq 1} { 273 set hdrs "Content-Type: text/plain;charset=utf-8\r\n" 274 if {$compfunc ne {none}} { 275 set hdrs [string cat $hdrs "Content-Encoding: $compfunc\r\n"] 276 set errmsg [repcompress $compfunc $errmsg] 277 } 278 set msglen [string length $errmsg] 279 set hdrs [string cat $hdrs "Content-Length: $msglen\r\nConnection: close\r\n"] 280 puts -nonewline $sock "HTTP/1.0 400 Bad Request\r\n$hdrs\r\n$errmsg" 281 } else { 282 puts -nonewline $sock "$errmsg" 283 } 284 flush $sock 285 } 286 287 # successful reply with data 288 proc repdata {sock ishttp data compfunc} { 289 if {$ishttp eq 1} { 290 set hdrs "Content-Type: text/plain;charset=utf-8\r\n" 291 if {$compfunc ne {none}} { 292 set hdrs [string cat $hdrs "Content-Encoding: $compfunc\r\n"] 293 set data [repcompress $compfunc $data] 294 } 295 set msglen [string length $data] 296 set hdrs [string cat $hdrs "Content-Length: $msglen\r\nConnection: close\r\n"] 297 puts -nonewline $sock "HTTP/1.0 200 OK\r\n$hdrs\r\n$data" 298 } else { 299 puts -nonewline $sock $data 300 } 301 flush $sock 302 } 303 304 # path router 305 # it only must write to the socket, not read from it or close it 306 # supported paths: /e, /m, /u/e, /u/m, /u/point, /list.txt, /blacklist.txt 307 proc routepath {dbfile sock ishttp path body compfunc} { 308 fconfigure $sock -translation binary 309 set pathparts [split [string trim $path] /] 310 if {[llength $pathparts] > 1} { 311 switch -- [lindex $pathparts 1] { 312 u { # /u/ subrequests 313 if {[llength $pathparts] > 2} { 314 switch -- [lindex $pathparts 2] { 315 e { 316 set erange [lrange $pathparts 3 end] 317 if {[llength $erange] > 0} { 318 set limit 0 319 set offset 0 320 set lastel [lindex $erange end] 321 if {[string match *?:?* $lastel]} { # slice detected 322 set sparts [split $lastel :] 323 set offset [expr {int([lindex $sparts 0])}] 324 set limit [expr {int([lindex $sparts 1])}] 325 set erange [lrange $erange 0 end-1] 326 } 327 # validate the rest of the echo list 328 set erange [lmap ename $erange {expr { 329 [validecho $ename] ? $ename : [continue] 330 }}] 331 if {[llength $erange] > 0} { # recheck length after validation 332 repdata $sock $ishttp [indexechos $dbfile $erange 1 $offset $limit] $compfunc 333 } else { 334 reperror $sock $ishttp "invalid request" $compfunc 335 } 336 } else { 337 reperror $sock $ishttp "invalid request" $compfunc 338 } 339 } 340 m { # validate and shape the message ID list 341 set mrange [lmap mid [lrange $pathparts 3 end] {expr { 342 [validmsgid $mid] ? $mid : [continue] 343 }}] 344 if {[llength $mrange] > 0} { # we have some valid messages 345 repdata $sock $ishttp [multimsg $dbfile $mrange] $compfunc 346 } else { 347 reperror $sock $ishttp "invalid request" $compfunc 348 } 349 } 350 point { 351 set msgbody "" 352 set authstr "" 353 if {$body ne ""} { # HTTP POST request 354 set params [qparams "?$body"] 355 if {[dict exists $params pauth]} { 356 set authstr [decurl [dict get $params pauth]] 357 } 358 if {[dict exists $params tmsg]} { 359 set msgbody [decurl [dict get $params tmsg]] 360 } 361 } else { # HTTP GET or a bare TCP request 362 if {[llength $pathparts] > 4} { 363 set authstr [lindex $pathparts 3] 364 set msgbody [join [lrange $pathparts 4 end] /] 365 # perform urlsafe substitution 366 set msgbody [string map {- + _ /} $msgbody] 367 } 368 } 369 if {$authstr ne "" && $msgbody ne ""} { 370 set postres [postmsg $dbfile $authstr $msgbody] 371 if [string match "msg ok*" $postres] { 372 repdata $sock $ishttp $postres $compfunc 373 } else { 374 reperror $sock $ishttp $postres $compfunc 375 } 376 } else { 377 reperror $sock $ishttp "invalid request" $compfunc 378 } 379 } 380 push { 381 set msgbody "" 382 set authstr "" 383 if {$body ne ""} { # HTTP POST request 384 set params [qparams "?$body"] 385 if {[dict exists $params pauth]} { 386 set authstr [decurl [dict get $params nauth]] 387 } 388 if {[dict exists $params tmsg]} { 389 set msgbody [decurl [dict get $params upush]] 390 } 391 } else { 392 reperror $sock $ishttp "/u/push is only available over HTTP POST" $compfunc 393 return 394 } 395 if {$authstr ne "" && $msgbody ne ""} { 396 set postres [postbundle $dbfile $authstr $msgbody] 397 if [string match "message saved: ok*" $postres] { 398 repdata $sock $ishttp $postres $compfunc 399 } else { 400 reperror $sock $ishttp $postres $compfunc 401 } 402 } else { 403 reperror $sock $ishttp "invalid request" $compfunc 404 } 405 } 406 default { 407 reperror $sock $ishttp "invalid request" $compfunc 408 } 409 } 410 } else { 411 reperror $sock $ishttp "invalid request" $compfunc 412 } 413 } 414 e { 415 if {[llength $pathparts] > 2} { 416 set echoname [string trim [lindex $pathparts 2]] 417 if {[validecho $echoname]} { 418 repdata $sock $ishttp [indexechos $dbfile [list $echoname] 0 0 0] $compfunc 419 } else { 420 reperror $sock $ishttp "invalid request" $compfunc 421 } 422 423 } else { 424 reperror $sock $ishttp "invalid request" $compfunc 425 } 426 } 427 m { 428 if {[llength $pathparts] > 2} { 429 set mid [string trim [lindex $pathparts 2]] 430 if {[validmsgid $mid]} { 431 repdata $sock $ishttp [singlemsg $dbfile $mid] $compfunc 432 } else { 433 reperror $sock $ishttp "invalid request" $compfunc 434 } 435 } else { 436 reperror $sock $ishttp "invalid request" $compfunc 437 } 438 } 439 list.txt { 440 repdata $sock $ishttp [listechos $dbfile] $compfunc 441 } 442 blacklist.txt { 443 repdata $sock $ishttp [blacklisted $dbfile] $compfunc 444 } 445 {} { 446 repdata $sock $ishttp [indexpage] $compfunc 447 } 448 default { 449 reperror $sock $ishttp "invalid request" $compfunc 450 } 451 } 452 } else { 453 reperror $sock $ishttp "invalid request" $compfunc 454 } 455 } 456 457 # main multiproto request handler 458 proc reqhandler {dbfile sock} { 459 # read the first request line 460 # ignore all invalid requests by closing the connection 461 if {[gets $sock line] >= 0} { 462 if {[string match /* $line]} { # bare TCP request (Gopher/Nex) 463 routepath $dbfile $sock 0 [string trim $line] {} none 464 } else { # HTTP request, read headers 465 set hdrread 0 466 set hdata {} 467 set docomp none 468 while {$hdrread < 1} { 469 if {[eof $sock] || [catch {gets $sock hline}]} { 470 break 471 } else { 472 if {$hline eq ""} { 473 incr hdrread 474 continue 475 } 476 set hparts [split [string trimleft $hline] :] 477 if {[llength $hparts] > 1} { 478 set hname [string tolower [string trimright [lindex $hparts 0]]] 479 set hval [string trim [lindex $hparts 1]] 480 dict set hdata $hname $hval 481 } 482 } 483 } 484 if {[dict exists $hdata accept-encoding]} { # detect if we can gzip the output 485 set enclist [dict get $hdata accept-encoding] 486 if {[string match -nocase {*gzip*} $enclist]} { 487 set docomp gzip 488 } elseif {[string match -nocase {*deflate*} $enclist]} { 489 set docomp deflate 490 } elseif {[string match -nocase {*compress*} $enclist]} { 491 set docomp compress 492 } 493 } 494 if {[string match -nocase {GET /*} $line]} { # GET request 495 set rparts [split [string trimleft $line]] 496 if {[llength $rparts] > 1} { # valid GET request 497 routepath $dbfile $sock 1 [lindex $rparts 1] {} $docomp 498 } 499 } elseif {[string match -nocase {POST /*} $line]} { # POST request 500 set rparts [split [string trimleft $line]] 501 if {[llength $rparts] > 1} { # valid POST request, read POST headers and data 502 set pdata {} 503 set readlen 0 504 if {[dict exists $hdata content-length]} { 505 set readlen [dict get $hdata content-length] 506 } 507 if {$readlen > 0} { # read the data defined by content-length header 508 fconfigure $sock -translation {binary lf} -buffering none 509 set pdata [read $sock $readlen] 510 } 511 routepath $dbfile $sock 1 [lindex $rparts 1] [string trimleft $pdata] $docomp 512 } 513 } 514 } 515 } 516 catch {close $sock} 517 } 518 519 # request accepter 520 proc reqaccept {dbfile sock addr port} { 521 # set linefeed as the newline character for output 522 # and translate anything into LF for input 523 fconfigure $sock -translation {auto lf} -buffering line 524 fileevent $sock readable [list reqhandler $dbfile $sock] 525 } 526 527 # entry point 528 if {$argc > 0} { 529 set listenport [expr {int([lindex $argv 0])}] 530 if {$listenport < 1 || $listenport > 65535} { 531 puts "Invalid port specified!" 532 exit 1 533 } 534 if {$argc > 1} { 535 set nodename [string trim [lindex $argv 1]] 536 } 537 if {$argc > 2} { 538 set localdb [file normalize [lindex $argv 2]] 539 } 540 } 541 # create the db file if it doesn't exist 542 if {![file exists $localdb]} { 543 puts "No DB found, creating..." 544 createdb $localdb 545 } 546 547 # start the server 548 puts "tiid daemon $nodename listening on port $listenport" 549 socket -server [list reqaccept $localdb] $listenport 550 vwait forever