tii

Tcl-based suite for working with ii/idec protocol
git clone git://git.luxferre.top/tii.git
Log | Files | Refs | README

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