382 lines
13 KiB
Tcl
Executable File
382 lines
13 KiB
Tcl
Executable File
#!/usr/bin/env tclsh
|
|
# tiifetch: fetch all data from an ii/idec station into the local text db
|
|
# (see https://github.com/idec-net/new-docs/blob/master/protocol-en.md)
|
|
# Usage: tiifetch.tcl [station_url] [echos] [db_file]
|
|
# The echo list should be delimited with slash (/), comma (,) or semicolon (;)
|
|
# if no echos are specified (or "" is passed), then list.txt will be fetched
|
|
# and then all missing echo content from it will be downloaded
|
|
# If db_dir isn't specified, it's fetched and merged into the
|
|
# tiidb directory in the program root with echoconfs and messages respectively
|
|
# This component only fetches the messages, doesn't parse or display them
|
|
# Supported protocols: HTTP, HTTPS, Gemini, Spartan, Gopher/Finger/Nex
|
|
# Depends on Tcllib for URI parsing and SQLite3 for data storage
|
|
# Created by Luxferre in 2024, released into public domain
|
|
|
|
package require http
|
|
package require uri
|
|
package require sqlite3
|
|
|
|
# autodetect TclTLS support and enable HTTPS request support if detected
|
|
set tls_support 0
|
|
catch {package require tls; set tls_support 1}
|
|
if {$tls_support eq 1} {
|
|
::http::register https 443 [list ::tls::socket -autoservername true]
|
|
}
|
|
|
|
proc url2dict {inputurl} {
|
|
set out [dict create]
|
|
if [regexp {^(.*)://} $inputurl _ lscheme] {
|
|
dict set out scheme $lscheme
|
|
} else {
|
|
dict set out handler "render_handler_invalid"
|
|
return $out
|
|
}
|
|
set rawout [::uri::split [regsub {^.*://} $inputurl "http://"]]
|
|
set rhost [dict get $rawout host]
|
|
set rpath [dict get $rawout path]
|
|
set rport [dict get $rawout port]
|
|
set secondarydata [dict get $rawout query]
|
|
dict set out host $rhost
|
|
set selector $rpath
|
|
dict set out handler "render_handler_$lscheme"
|
|
# protocol-specific request logic
|
|
switch "$lscheme" {
|
|
gophers -
|
|
gopher {
|
|
if {$rport eq ""} {set rport 70}
|
|
set selector [string cat [string range $rpath 1 end] "\r\n"]
|
|
dict set out handler render_handler_gopher
|
|
}
|
|
finger {
|
|
if {$rport eq ""} {set rport 79}
|
|
set selector "$selector\r\n"
|
|
}
|
|
spartan {
|
|
if {$rport eq ""} {set rport 300}
|
|
if {$rpath eq ""} {set rpath "/"}
|
|
if {![string match "/*" $rpath]} {
|
|
set rpath "/$rpath"
|
|
}
|
|
set blen [string length $secondarydata]
|
|
set selector "$rhost $rpath $blen\r\n$secondarydata"
|
|
}
|
|
nex {
|
|
if {$rport eq ""} {set rport 1900}
|
|
set selector "$selector\r\n"
|
|
}
|
|
gemini {
|
|
if {$rport eq ""} {set rport 1965}
|
|
set selector "$inputurl\r\n"
|
|
}
|
|
default {dict set out handler render_handler_none}
|
|
}
|
|
dict set out path $rpath
|
|
dict set out selector $selector
|
|
dict set out port $rport
|
|
return $out
|
|
}
|
|
|
|
proc reqresp {host port reqline is_tls encoding} {
|
|
global sock_response
|
|
set sock_net_timeout 5000
|
|
set sock 0
|
|
if {$is_tls eq 1} {
|
|
catch {set sock [::tls::socket -autoservername true -async $host $port]}
|
|
} elseif {$is_tls eq 2} {
|
|
catch {set sock [::tls::socket -autoservername true -async $host $port]}
|
|
if {$sock eq 0} {catch {set sock [socket -async $host $port]}}
|
|
} else {
|
|
catch {set sock [socket -async $host $port]}
|
|
}
|
|
if {$sock eq 0} {set sock_response ""; return}
|
|
global rcv_end_$sock
|
|
unset -nocomplain rcv_end_$sock
|
|
if {$encoding eq ""} {set encoding utf-8}
|
|
fconfigure $sock -translation binary -buffering none -encoding $encoding
|
|
fileevent $sock writable [list connected $sock $reqline]
|
|
proc connected {sock reqline} {
|
|
fileevent $sock writable {}
|
|
puts -nonewline $sock "$reqline"
|
|
flush $sock
|
|
fileevent $sock readable [list rdbl $sock]
|
|
}
|
|
set sock_response ""
|
|
proc rdbl {sock} {
|
|
global sock_response rcv_end_$sock
|
|
while {![eof $sock]} {
|
|
append sock_response [read $sock]
|
|
}
|
|
set rcv_end_$sock 0
|
|
}
|
|
after $sock_net_timeout "global rcv_end_$sock; set rcv_end_$sock 1"
|
|
vwait rcv_end_$sock
|
|
catch {close $sock}
|
|
unset -nocomplain rcv_end_$sock
|
|
}
|
|
|
|
|
|
# file download helper
|
|
proc getfile {url} {
|
|
set url [regsub -all {([^:])//} $url {\1/}]
|
|
set urlparts [url2dict $url]
|
|
set scheme [dict get $urlparts scheme]
|
|
set host [dict get $urlparts host]
|
|
set port [dict get $urlparts port]
|
|
set sel [dict get $urlparts selector]
|
|
global sock_response tls_support
|
|
switch $tls_support {
|
|
0 {set localtls 0}
|
|
1 {set localtls 2}
|
|
}
|
|
switch $scheme {
|
|
gophers - gopher - finger - nex {
|
|
if {$scheme eq "gophers"} {set localtls 1}
|
|
reqresp $host $port $sel $localtls utf-8
|
|
set body "$sock_response"
|
|
set sock_response ""
|
|
return $body
|
|
}
|
|
gemini - spartan {
|
|
if {$scheme eq "gemini"} {set localtls 1}
|
|
reqresp $host $port $sel $localtls utf-8
|
|
set body "$sock_response"
|
|
set sock_response ""
|
|
if {[regexp {^([^\n]*)\n} $body _ statusline]} {
|
|
set statusline [string trimright $statusline]
|
|
set statusparts [split $statusline " "]
|
|
set statuscode [lindex $statusparts 0]
|
|
set mainstatuscode [string index $statuscode 0]
|
|
if {$mainstatuscode eq 2} {
|
|
regsub {.*?\n} $body "" body
|
|
return $body
|
|
} else {return {}}
|
|
} else {return {}}
|
|
}
|
|
https - http {
|
|
set hs [::http::geturl $url -binary 1 -keepalive 1 -timeout 5000]
|
|
if {[::http::ncode $hs] eq "200"} {
|
|
return [::http::data $hs]
|
|
} else {return {}}
|
|
}
|
|
default {return {}}
|
|
}
|
|
}
|
|
|
|
# file read helper
|
|
proc readfile {fname} {
|
|
set fp [open $fname r]
|
|
fconfigure $fp -encoding utf-8
|
|
set data [read $fp]
|
|
close $fp
|
|
return $data
|
|
}
|
|
|
|
# file write helper (leaves a newline at the end)
|
|
proc writefileln {fname data} {
|
|
set fp [open $fname w]
|
|
fconfigure $fp -encoding utf-8
|
|
puts $fp $data
|
|
close $fp
|
|
}
|
|
|
|
# list comparison helper (listcomp $new $old)
|
|
proc listcomp {a b} {
|
|
set diff {}
|
|
foreach i $a {
|
|
if {[lsearch -exact $b $i]==-1} {
|
|
lappend diff $i
|
|
}
|
|
}
|
|
return $diff
|
|
}
|
|
|
|
# main logic proc
|
|
proc fetchiidb {url echos dbfile dolog maxids} {
|
|
if {$maxids < 12} {set maxids 12}
|
|
# trim the parameters
|
|
set url [string trim $url]
|
|
set echos [string trim $echos]
|
|
set dbfile [file normalize [string trim $dbfile]]
|
|
# prepare starting script
|
|
sqlite3 msgdb $dbfile
|
|
msgdb eval {
|
|
CREATE TABLE IF NOT EXISTS `msg` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `msgid` VARCHAR(20), `timestamp` INT, `echoname` VARCHAR(120),
|
|
`repto` TEXT, `msgfrom` TEXT, `msgfromaddr` TEXT, `msgto` TEXT, `subj` TEXT, `body` TEXT);
|
|
}
|
|
|
|
# attempt to fetch the echolist if echos are empty
|
|
if {$echos eq {}} {
|
|
if {$dolog eq 1} {puts "Fetching echolist..."}
|
|
set echolist [getfile "$url/list.txt"]
|
|
set echos [lmap e [split $echolist "\n"] {lindex [split $e ":"] 0}]
|
|
} else {
|
|
set echos [split $echos "/,;"]
|
|
}
|
|
if {$dolog eq 1} {puts "Echos to fetch: $echos"}
|
|
set echos [lmap s $echos {string trim $s}]
|
|
# get the IDEC extended feature list
|
|
set featurelist ""
|
|
catch {set featurelist [getfile [string cat $url "/x/features"]]}
|
|
set featurelist [lmap s [split $featurelist \n] {string trim $s}]
|
|
set datalines ""
|
|
if {([lsearch $featurelist x/c] > -1 ) && ([lsearch $featurelist u/e] > -1)} {
|
|
# get message count in every echo of our choice
|
|
set countdata [getfile [string cat $url "/x/c/" [join $echos "/"]]]
|
|
foreach cd $countdata {
|
|
set cdparts [split $cd :]
|
|
if {[llength $cdparts] > 1} {
|
|
set ename [lindex $cdparts 0]
|
|
set rcount [lindex $cdparts 1]
|
|
set lcount [msgdb eval {SELECT COUNT (DISTINCT `id`) FROM `msg` WHERE `echoname` = $ename;}]
|
|
# get the difference between remote and local counts
|
|
set diff [expr {$rcount - $lcount}]
|
|
if {$diff > 0} { # we have something to fetch
|
|
set localdata [getfile [string cat $url "/u/e/" $ename "/-$diff:$diff"]]
|
|
append datalines [string trim $localdata] \n
|
|
}
|
|
}
|
|
}
|
|
|
|
} else { # no extended feature support, pass the echo list and fetch the message IDs
|
|
set echodata [getfile [string cat $url "/u/e/" [join $echos "/"]]]
|
|
set datalines [split $echodata \n]
|
|
}
|
|
# iterate over the fetched data and fetch corresponding messages
|
|
set curecho ""
|
|
set echomap ""
|
|
# build the map of lists of message IDs
|
|
foreach line $datalines {
|
|
set line [string trim $line]
|
|
if {$line ne ""} {
|
|
# detect if the line is related to echo name or message ID
|
|
if {[string first "." $line] eq -1} { # message ID
|
|
if {[string length $line] == 20} { # filter out invalid IDs
|
|
if {$curecho ne ""} {
|
|
dict lappend echomap $curecho $line
|
|
}
|
|
}
|
|
} else { # echo name
|
|
set curecho $line
|
|
dict set echomap $curecho ""
|
|
}
|
|
}
|
|
}
|
|
if {$dolog eq 1} {puts "Echomap built"}
|
|
# pass the echo list and fetch the message IDs
|
|
# now, process the map we've built
|
|
dict for {echoname msgids} $echomap {
|
|
if {![string match *.* $echoname]} {continue}
|
|
if {[llength msgids] eq 0} {continue}
|
|
# get the existing message IDs in the echo
|
|
set oldmsgids [msgdb eval {SELECT `msgid` FROM `msg` WHERE `echoname` = $echoname ORDER BY `id` ASC;}]
|
|
# pre-filter the new message IDs to fetch
|
|
set newmsgids [listcomp $msgids $oldmsgids]
|
|
if {$dolog eq 1} {puts "Fetching [llength $newmsgids] new messages from $echoname..."}
|
|
set idgroups ""
|
|
set grcount 0
|
|
set localcount 0
|
|
foreach nmid $newmsgids { # iterate over new messages to group them
|
|
if {$nmid ne ""} {
|
|
# insert new message ID to the echo mapping
|
|
dict lappend idgroups $grcount $nmid
|
|
incr localcount
|
|
if {$localcount > $maxids} {
|
|
incr grcount
|
|
set localcount 0
|
|
}
|
|
}
|
|
}
|
|
dict for {mgrpind mgrp} $idgroups { # iterate over groups to fetch the messages
|
|
# get the message data in the bundle format
|
|
set msgbundle [getfile [string cat $url "/u/m/" [join $mgrp "/"]]]
|
|
set bdata [split $msgbundle "\n"]
|
|
foreach bline $bdata {
|
|
set parts [split $bline ":"]
|
|
if {[llength $parts] > 1} { # valid message
|
|
set mid [lindex $parts 0]
|
|
set mdata [encoding convertfrom utf-8 [binary decode base64 [lindex $parts 1]]]
|
|
set msglines [split $mdata "\n"]
|
|
set replyto ""
|
|
set tags [split [lindex $msglines 0] "/"]
|
|
if {[dict exists $tags repto]} {
|
|
set replyto [dict get $tags repto]
|
|
} else {set replyto ""}
|
|
set echoarea [string trim [lindex $msglines 1]]
|
|
set timestamp [string trim [lindex $msglines 2]]
|
|
set msgfrom [string trim [lindex $msglines 3]]
|
|
set msgfromaddr [string trim [lindex $msglines 4]]
|
|
set msgto [string trim [lindex $msglines 5]]
|
|
set subj [string trim [lindex $msglines 6]]
|
|
set msgbody [string trimright [lrange $msglines 8 end]]
|
|
msgdb eval {INSERT INTO `msg` (`msgid`, `timestamp`, `echoname`, `repto`, `msgfrom`, `msgfromaddr`, `msgto`, `subj`, `body`)
|
|
VALUES ($mid, $timestamp, $echoarea, $replyto, $msgfrom, $msgfromaddr, $msgto, $subj, $msgbody);}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
msgdb close
|
|
}
|
|
|
|
proc massfetch {echos db dolog} {
|
|
global appdir
|
|
if {$dolog eq 1} {puts "No ii/idec station URL specified, using stations.txt"}
|
|
set stfile [file join $appdir "stations.txt"]
|
|
if {[file exists $stfile]} {
|
|
set stlist [readfile $stfile]
|
|
dict for {station stmaxids} $stlist {
|
|
set station [string trim $station]
|
|
if {$station ne "" && ![string match "#*" $station]} {
|
|
if {$dolog eq 1} {puts "Fetching from $station"}
|
|
fetchiidb $station $echos $db $dolog $stmaxids
|
|
}
|
|
}
|
|
} else {
|
|
if {$dolog eq 1} {puts "No stations.txt found, bailing out!"}
|
|
}
|
|
}
|
|
|
|
# end of procs, start the entrypoint
|
|
if {![info exists argv0] || [file tail [info script]] ne [file tail $argv0]} {return}
|
|
|
|
set scriptpath [file normalize [info script]]
|
|
set appdir [file dirname $scriptpath]
|
|
# check if we're running from a starpack
|
|
if [string match *app-tiifetch $appdir] {
|
|
set appdir [file normalize [file join $appdir ".." ".." ".." ]]
|
|
}
|
|
set localdb [file join $appdir "tii.db"]
|
|
|
|
# populate general HTTP configuration
|
|
set cfgfile [file join $appdir "config.txt"]
|
|
if {[file exists $cfgfile]} {
|
|
set cfg [readfile $cfgfile]
|
|
if {[dict exists $cfg useragent]} {
|
|
::http::config -useragent [dict get $cfg useragent]
|
|
}
|
|
if {[dict exists $cfg proxyhost]} {
|
|
::http::config -proxyhost [dict get $cfg proxyhost]
|
|
}
|
|
if {[dict exists $cfg proxyport]} {
|
|
::http::config -proxyport [dict get $cfg proxyport]
|
|
}
|
|
}
|
|
|
|
if {$argc > 0} {
|
|
if {$argc > 2} {
|
|
set localdb [lindex $argv 2]
|
|
}
|
|
puts "Fetching messages, please wait..."
|
|
set sturl [string trim [lindex $argv 0]]
|
|
if {$sturl eq ""} {
|
|
massfetch [lindex $argv 1] $localdb 1
|
|
} else {
|
|
fetchiidb $sturl [lindex $argv 1] $localdb 1 12
|
|
}
|
|
puts "Messages fetched"
|
|
} else {
|
|
puts "Fetching messages, please wait..."
|
|
massfetch "" $localdb 1
|
|
puts "Messages fetched"
|
|
}
|
|
|