# ACTIVESTATE TEAPOT-PKG BEGIN TM -*- tcl -*- # -- Tcl Module # @@ Meta Begin # Package S3 1.0.2 # Meta as::build::date 2015-04-24 # Meta as::origin http://sourceforge.net/projects/tcllib # Meta category Amazon S3 Web Service Utilities # Meta description Amazon S3 Web Service Interface # Meta license BSD # Meta platform tcl # Meta require {Tcl 8.5} # Meta require base64 # Meta require md5 # Meta require sha1 # Meta require xsxp # Meta subject s3 amazon cloud # Meta summary S3 # @@ Meta End # ACTIVESTATE TEAPOT-PKG BEGIN REQUIREMENTS package require Tcl 8.5 package require base64 package require md5 package require sha1 package require xsxp # ACTIVESTATE TEAPOT-PKG END REQUIREMENTS # ACTIVESTATE TEAPOT-PKG BEGIN DECLARE package provide S3 1.0.2 # ACTIVESTATE TEAPOT-PKG END DECLARE # ACTIVESTATE TEAPOT-PKG END TM # S3.tcl # ###Abstract # This presents an interface to Amazon's S3 service. # The Amazon S3 service allows for reliable storage # and retrieval of data via HTTP. # # Copyright (c) 2006,2008 Darren New. All Rights Reserved. # ###Copyright # NO WARRANTIES OF ANY TYPE ARE PROVIDED. # COPYING OR USE INDEMNIFIES THE AUTHOR IN ALL WAYS. # # This software is licensed under essentially the same # terms as Tcl. See LICENSE.txt for the terms. # ###Revision String # SCCS: %Z% %M% %I% %E% %U% # ###Change history: # 0.7.2 - added -default-bucket. # 0.8.0 - fixed bug in getLocal using wrong prefix. # Upgraded to Tcl 8.5 release version. # 1.0.0 - added SetAcl, GetAcl, and -acl keep option. # package require Tcl 8.5 # This is by Darren New too. # It is a SAX package to format XML for easy retrieval. # It should be in the same distribution as S3. package require xsxp # These three are required to do the auth, so always require them. # Note that package registry and package fileutil are required # by the individual routines that need them. Grep for "package". package require sha1 package require md5 package require base64 package provide S3 1.0.2 namespace eval S3 { variable config ; # A dict holding the current configuration. variable config_orig ; # Holds configuration to "reset" back to. variable debug 0 ; # Turns on or off S3::debug variable debuglog 0 ; # Turns on or off debugging into a file variable bgvar_counter 0 ; # Makes unique names for bgvars. set config_orig [dict create \ -reset false \ -retries 3 \ -accesskeyid "" -secretaccesskey "" \ -service-access-point "s3.amazonaws.com" \ -slop-seconds 3 \ -use-tls false \ -bucket-prefix "TclS3" \ -default-compare "always" \ -default-separator "/" \ -default-acl "" \ -default-bucket "" \ ] set config $config_orig } # Internal, for development. Print a line, and maybe log it. proc S3::debuglogline {line} { variable debuglog puts $line if {$debuglog} { set x [open debuglog.txt a] puts $x $line close $x } } # Internal, for development. Print debug info properly formatted. proc S3::debug {args} { variable debug variable debuglog if {!$debug} return set res "" if {"-hex" == [lindex $args 0]} { set str [lindex $args 1] foreach ch [split $str {}] { scan $ch %c val append res [format %02x $val] append res " " } debuglogline $res return } if {"-dict" == [lindex $args 0]} { set dict [lindex $args 1] debuglogline "DEBUG dict:" foreach {key val} $dict { set val [string map [list \ \r \\r \n \\n \0 \\0 ] $val] debuglogline "$key=$val" } return } set x [string map [list \ \r \\r \n \\n \0 \\0 ] $args] debuglogline "DEBUG: $x" } # Internal. Throws an error if keys have not been initialized. proc S3::checkinit {} { variable config set error "S3 must be initialized with -accesskeyid and -secretaccesskey before use" set e1 {S3 usage -accesskeyid "S3 identification not initialized"} set e2 {S3 usage -secretaccesskey "S3 identification not initialized"} if {[dict get $config -accesskeyid] eq ""} { error $error "" $e1 } if {[dict get $config -secretaccesskey] eq ""} { error $error "" $e2 } } # Internal. Calculates the Content-Type for a given file name. # Naturally returns application/octet-stream if anything goes wrong. proc S3::contenttype {fname} { if {$::tcl_platform(platform) == "windows"} { set extension [file extension $fname] uplevel #0 package require registry set key "\\\\HKEY_CLASSES_ROOT\\" set key "HKEY_CLASSES_ROOT\\" if {"." != [string index $extension 0]} {append key .} append key $extension set ct "application/octet-stream" if {$extension != ""} { catch {set ct [registry get $key {Content Type}]} caught } } else { # Assume something like Unix. if {[file readable /etc/mime.types]} { set extension [string trim [file extension $fname] "."] set f [open /etc/mime.types r] while {-1 != [gets $f line] && ![info exists c]} { set line [string trim $line] if {[string match "#*" $line]} continue if {0 == [string length $line]} continue set items [split $line] for {set i 1} {$i < [llength $items]} {incr i} { if {[lindex $items $i] eq $extension} { set c [lindex $items 0] break } } } close $f if {![info exists c]} { set ct "application/octet-stream" } else { set ct [string trim $c] } } else { # No /etc/mime.types here. if {[catch {exec file -i $fname} res]} { set ct "application/octet-stream" } else { set ct [string range $res [expr {1+[string first : $res]}] end] if {-1 != [string first ";" $ct]} { set ct [string range $ct 0 [string first ";" $ct]] } set ct [string trim $ct "; "] } } } return $ct } # Change current configuration. Not object-oriented, so only one # configuration is tracked per interpreter. proc S3::Configure {args} { variable config variable config_orig if {[llength $args] == 0} {return $config} if {[llength $args] == 1 && ![dict exists $config [lindex $args 0]]} { error "Bad option \"[lindex $args 0]\": must be [join [dict keys $config] ,\ ]" "" [list S3 usage [lindex $args 0] "Bad option to config"] } if {[llength $args] == 1} {return [dict get $config [lindex $args 0]]} if {[llength $args] % 2 != 0} { error "Config args must be -name val -name val" "" [list S3 usage [lindex $args end] "Odd number of config args"] } set new $config foreach {tag val} $args { if {![dict exists $new $tag]} { error "Bad option \"$tag\": must be [join [dict keys $config] ,\ ]" "" [list S3 usage $tag "Bad option to config"] } dict set new $tag $val if {$tag eq "-reset" && $val} { set new $config_orig } } if {[dict get $config -use-tls]} { error "TLS for S3 not yet implemented!" "" \ [list S3 notyet -use-tls $config] } set config $new ; # Only update if all went well return $config } # Suggest a unique bucket name based on usename and config info. proc S3::SuggestBucket {{usename ""}} { checkinit if {$usename eq ""} {set usename [::S3::Configure -bucket-prefix]} if {$usename eq ""} { error "S3::SuggestBucket requires name or -bucket-prefix set" \ "" [list S3 usage -bucket-prefix] } return $usename\.[::S3::Configure -accesskeyid] } # Calculate authorization token for REST interaction. # Doesn't work yet for "Expires" type headers. Hence, only for "REST". # We specifically don't call checkinit because it's called in all # callers and we don't want to throw an error inside here. # Caveat Emptor if you expect otherwise. # This is internal, but useful enough you might want to invoke it. proc S3::authREST {verb resource content-type headers args} { if {[llength $args] != 0} { set body [lindex $args 0] ; # we use [info exists] later } if {${content-type} != "" && [dict exists $headers content-type]} { set content-type [dict get $headers content-type] } dict unset headers content-type set verb [string toupper $verb] if {[info exists body]} { set content-md5 [::base64::encode [::md5::md5 $body]] dict set headers content-md5 ${content-md5} dict set headers content-length [string length $body] } elseif {[dict exists $headers content-md5]} { set content-md5 [dict get $headers content-md5] } else { set content-md5 "" } if {[dict exists $headers x-amz-date]} { set date "" dict unset headers date } elseif {[dict exists $headers date]} { set date [dict get $headers date] } else { set date [clock format [clock seconds] -gmt true -format \ "%a, %d %b %Y %T %Z"] dict set headers date $date } if {${content-type} != ""} { dict set headers content-type ${content-type} } dict set headers host s3.amazonaws.com set xamz "" foreach key [lsort [dict keys $headers x-amz-*]] { # Assume each is seen only once, for now, and is canonical already. append xamz \n[string trim $key]:[string trim [dict get $headers $key]] } set xamz [string trim $xamz] # Hmmm... Amazon lies. No \n after xamz if xamz is empty. if {0 != [string length $xamz]} {append xamz \n} set signthis \ "$verb\n${content-md5}\n${content-type}\n$date\n$xamz$resource" S3::debug "Sign this:" $signthis ; S3::debug -hex $signthis set sig [::sha1::hmac [S3::Configure -secretaccesskey] $signthis] set sig [binary format H* $sig] set sig [string trim [::base64::encode $sig]] dict set headers authorization "AWS [S3::Configure -accesskeyid]:$sig" return $headers } # Internal. Takes resource and parameters, tacks them together. # Useful enough you might want to invoke it yourself. proc S3::to_url {resource parameters} { if {0 == [llength $parameters]} {return $resource} if {-1 == [string first "?" $resource]} { set front ? } else { set front & } foreach {key value} $parameters { append resource $front $key "=" $value set front & } return $resource } # Internal. Encode a URL, including utf-8 versions. # Useful enough you might want to invoke it yourself. proc S3::encode_url {orig} { set res "" set re {[-a-zA-Z0-9/.,_]} foreach ch [split $orig ""] { if {[regexp $re $ch]} { append res $ch } else { foreach uch [split [encoding convertto utf-8 $ch] ""] { append res "%" binary scan $uch H2 hex append res $hex } } } if {$res ne $orig} { S3::debug "URL Encoded:" $orig $res } return $res } # This is used internally to either queue an event-driven # item or to simply call the next routine, depending on # whether the current transaction is supposed to be running # in the background or not. proc S3::nextdo {routine thunk direction args} { global errorCode S3::debug "nextdo" $routine $thunk $direction $args if {[dict get $thunk blocking]} { return [S3::$routine $thunk] } else { if {[llength $args] == 2} { # fcopy failed! S3::fail "S3 fcopy failed: [lindex $args 1]" "" \ [list S3 socket $errorCode] } else { fileevent [dict get $thunk S3chan] $direction \ [list S3::$routine $thunk] if {$direction == "writable"} { fileevent [dict get $thunk S3chan] readable {} } else { fileevent [dict get $thunk S3chan] writable {} } } } } # The proverbial It. Do a REST call to Amazon S3 service. proc S3::REST {orig} { variable config checkinit set EndPoint [dict get $config -service-access-point] # Save the original stuff first. set thunk [dict create orig $orig] # Now add to thunk's top-level the important things if {[dict exists $thunk orig resultvar]} { dict set thunk blocking 0 } else { dict set thunk blocking 1 } if {[dict exists $thunk orig S3chan]} { dict set thunk S3chan [dict get $thunk orig S3chan] } elseif {[dict get $thunk blocking]} { dict set thunk S3chan [socket $EndPoint 80] } else { dict set thunk S3chan [socket -async $EndPoint 80] } fconfigure [dict get $thunk S3chan] -translation binary -encoding binary dict set thunk verb [dict get $thunk orig verb] dict set thunk resource [S3::encode_url [dict get $thunk orig resource]] if {[dict exists $orig rtype]} { dict set thunk resource \ [dict get $thunk resource]?[dict get $orig rtype] } if {[dict exists $orig headers]} { dict set thunk headers [dict get $orig headers] } else { dict set thunk headers [dict create] } if {[dict exists $orig infile]} { dict set thunk infile [dict get $orig infile] } if {[dict exists $orig content-type]} { dict set thunk content-type [dict get $orig content-type] } else { if {[dict exists $thunk infile]} { set zz [dict get $thunk infile] } else { set zz [dict get $thunk resource] } if {-1 != [string first "?" $zz]} { set zz [string range $zz 0 [expr {[string first "?" $zz]-1}]] set zz [string trim $zz] } if {$zz != ""} { catch {dict set thunk content-type [S3::contenttype $zz]} } else { dict set thunk content-type application/octet-stream dict set thunk content-type "" } } set p {} if {[dict exist $thunk orig parameters]} { set p [dict get $thunk orig parameters] } dict set thunk url [S3::to_url [dict get $thunk resource] $p] if {[dict exists $thunk orig inbody]} { dict set thunk headers [S3::authREST \ [dict get $thunk verb] [dict get $thunk resource] \ [dict get $thunk content-type] [dict get $thunk headers] \ [dict get $thunk orig inbody] ] } else { dict set thunk headers [S3::authREST \ [dict get $thunk verb] [dict get $thunk resource] \ [dict get $thunk content-type] [dict get $thunk headers] ] } # Not the best place to put this code. if {![info exists body] && [dict exists $thunk infile]} { set size [file size [dict get $thunk infile]] set x [dict get $thunk headers] dict set x content-length $size dict set thunk headers $x } # Ready to go! return [S3::nextdo send_headers $thunk writable] } # Internal. Send the headers to Amazon. Might block if you have # really small socket buffers, but Amazon doesn't want # data that big anyway. proc S3::send_headers {thunk} { S3::debug "Send-headers" $thunk set s3 [dict get $thunk S3chan] puts $s3 "[dict get $thunk verb] [dict get $thunk url] HTTP/1.0" S3::debug ">> [dict get $thunk verb] [dict get $thunk url] HTTP/1.0" foreach {key val} [dict get $thunk headers] { puts $s3 "$key: $val" S3::debug ">> $key: $val" } puts $s3 "" flush $s3 return [S3::nextdo send_body $thunk writable] } # Internal. Send the body to Amazon. proc S3::send_body {thunk} { global errorCode set s3 [dict get $thunk S3chan] if {[dict exists $thunk orig inbody]} { # Send a string. Let's guess that even in non-blocking # mode, this is small enough or Tcl's smart enough that # we don't blow up the buffer. puts -nonewline $s3 [dict get $thunk orig inbody] flush $s3 return [S3::nextdo read_headers $thunk readable] } elseif {![dict exists $thunk orig infile]} { # No body, no file, so nothing more to do. return [S3::nextdo read_headers $thunk readable] } elseif {[dict get $thunk blocking]} { # A blocking file copy. Still not too hard. if {[catch {set inchan [open [dict get $thunk infile] r]} caught]} { S3::fail $thunk "S3 could not open infile - $caught" "" \ [list S3 local [dict get $thunk infile] $errorCode] } fconfigure $inchan -translation binary -encoding binary fileevent $s3 readable {} fileevent $s3 writable {} if {[catch {fcopy $inchan $s3 ; flush $s3 ; close $inchan} caught]} { S3::fail $thunk "S3 could not copy infile - $caught" "" \ [list S3 local [dict get $thunk infile] $errorCode] } S3::nextdo read_headers $thunk readable } else { # The hard one. Background file copy. fileevent $s3 readable {} fileevent $s3 writable {} if {[catch {set inchan [open [dict get $thunk infile] r]} caught]} { S3::fail $thunk "S3 could not open infile - $caught" "" \ [list S3 local [dict get $thunk infile] $errorCode] } fconfigure $inchan -buffering none -translation binary -encoding binary fconfigure $s3 -buffering none -translation binary \ -encoding binary -blocking 0 ; # Doesn't work without this? dict set thunk inchan $inchan ; # So we can close it. fcopy $inchan $s3 -command \ [list S3::nextdo read_headers $thunk readable] } } # Internal. The first line has come back. Grab out the # stuff we care about. proc S3::parse_status {thunk line} { # Got the status line S3::debug "<< $line" dict set thunk httpstatusline [string trim $line] dict set thunk outheaders [dict create] regexp {^HTTP/1.. (...) (.*)$} $line junk code message dict set thunk httpstatus $code dict set thunk httpmessage [string trim $message] return $thunk } # A line of header information has come back. Grab it. # This probably is unhappy with multiple lines for one # header. proc S3::parse_header {thunk line} { # Got a header line. For now, assume no continuations. S3::debug "<< $line" set line [string trim $line] set left [string range $line 0 [expr {[string first ":" $line]-1}]] set right [string range $line [expr {[string first ":" $line]+1}] end] set left [string trim [string tolower $left]] set right [string trim $right] dict set thunk outheaders $left $right return $thunk } # I don't know if HTTP requires a blank line after the headers if # there's no body. # Internal. Read all the headers, and throw if we get EOF before # we get any headers at all. proc S3::read_headers {thunk} { set s3 [dict get $thunk S3chan] flush $s3 fconfigure $s3 -blocking [dict get $thunk blocking] if {[dict get $thunk blocking]} { # Blocking. Just read to a blank line. Otherwise, # if we use nextdo here, we wind up nesting horribly. # If we're not blocking, of course, we're returning # to the event loop each time, so that's OK. set count [gets $s3 line] if {[eof $s3]} { S3::fail $thunk "S3 EOF during status line read" "" "S3 socket EOF" } set thunk [S3::parse_status $thunk $line] while {[string trim $line] != ""} { set count [gets $s3 line] if {$count == -1 && 0 == [dict size [dict get $thunk outheaders]]} { S3::fail $thunk "S3 EOF during headers read" "" "S3 socket EOF" } if {[string trim $line] != ""} { set thunk [S3::parse_header $thunk $line] } } return [S3::nextdo read_body $thunk readable] } else { # Non-blocking, so we have to reenter for each line. # First, fix up the file handle, tho. if {[dict exists $thunk inchan]} { close [dict get $thunk inchan] dict unset thunk inchan } # Now get one header. set count [gets $s3 line] if {[eof $s3]} { fileevent $s3 readable {} fileevent $s3 writable {} if {![dict exists $thunk httpstatusline]} { S3::fail $thunk "S3 EOF during status line read" "" "S3 socket EOF" } elseif {0 == [dict size [dict get $thunk outheaders]]} { S3::fail $thunk "S3 EOF during header read" "" "S3 socket EOF" } } if {$count < 0} return ; # Wait for a whole line set line [string trim $line] if {![dict exists $thunk httpstatus]} { set thunk [S3::parse_status $thunk $line] S3::nextdo read_headers $thunk readable ; # New thunk here. } elseif {$line != ""} { set thunk [S3::parse_header $thunk $line] S3::nextdo read_headers $thunk readable ; # New thunk here. } else { # Got an empty line. Switch to copying the body. S3::nextdo read_body $thunk readable } } } # Internal. Read the body of the response. proc S3::read_body {thunk} { set s3 [dict get $thunk S3chan] if {[dict get $thunk blocking]} { # Easy. Just read it. if {[dict exists $thunk orig outchan]} { fcopy $s3 [dict get $thunk orig outchan] } else { set x [read $s3] dict set thunk outbody $x #S3::debug "Body: $x" -- Disable unconditional wasteful conversion to string #Need better debug system which does this only when active. } return [S3::nextdo all_done $thunk readable] } else { # Nonblocking mode. if {[dict exists $thunk orig outchan]} { fileevent $s3 readable {} fileevent $s3 writable {} fcopy $s3 [dict get $thunk orig outchan] -command \ [list S3::nextdo all_done $thunk readable] } else { dict append thunk outbody [read $s3] if {[eof $s3]} { # We're done. S3::nextdo all_done $thunk readable } else { S3::nextdo read_body $thunk readable } } } } # Internal. Convenience function. proc S3::fail {thunk error errorInfo errorCode} { S3::all_done $thunk $error $errorInfo $errorCode } # Internal. We're all done the transaction. Clean up everything, # potentially record errors, close channels, etc etc etc. proc S3::all_done {thunk {error ""} {errorInfo ""} {errorCode ""}} { set s3 [dict get $thunk S3chan] catch { fileevent $s3 readable {} fileevent $s3 writable {} } if {![dict exists $thunk orig S3chan]} { catch {close $s3} } set res [dict get $thunk orig] catch { dict set res httpstatus [dict get $thunk httpstatus] dict set res httpmessage [dict get $thunk httpmessage] dict set res outheaders [dict get $thunk outheaders] } if {![dict exists $thunk orig outchan]} { if {[dict exists $thunk outbody]} { dict set res outbody [dict get $thunk outbody] } else { # Probably HTTP failure dict set rest outbody {} } } if {$error ne ""} { dict set res error $error dict set res errorInfo $errorInfo dict set res errorCode $errorCode } if {![dict get $thunk blocking]} { after 0 [list uplevel #0 \ [list set [dict get $thunk orig resultvar] $res]] } if {$error eq "" || ![dict get $thunk blocking] || \ ([dict exists $thunk orig throwsocket] && \ "return" == [dict get $thunk orig throwsocket])} { return $res } else { error $error $errorInfo $errorCode } } # Internal. Parse the lst and make sure it has only keys from the 'valid' list. # Used to parse arguments going into the higher-level functions. proc S3::parseargs1 {lst valid} { if {[llength $lst] % 2 != 0} { error "Option list must be even -name val pairs" \ "" [list S3 usage [lindex $lst end] $lst] } foreach {key val} $lst { # Sadly, lsearch applies -glob to the wrong thing for our needs set found 0 foreach v $valid { if {[string match $v $key]} {set found 1 ; break} } if {!$found} { error "Option list has invalid -key" \ "" [list S3 usage $key $lst] } } return $lst ; # It seems OK } # Internal. Create a variable for higher-level functions to vwait. proc S3::bgvar {} { variable bgvar_counter incr bgvar_counter set name ::S3::bgvar$bgvar_counter return $name } # Internal. Given a request and the arguments, run the S3::REST in # the foreground or the background as appropriate. Also, do retries # for internal errors. proc S3::maybebackground {req myargs} { variable config global errorCode errorInfo set mytries [expr {1+[dict get $config -retries]}] set delay 2000 dict set req throwsocket return while {1} { if {![dict exists $myargs -blocking] || [dict get $myargs -blocking]} { set dict [S3::REST $req] } else { set res [bgvar] dict set req resultvar $res S3::REST $req vwait $res set dict [set $res] unset $res ; # clean up temps } if {[dict exists $dict error]} { set code [dict get $dict errorCode] if {"S3" != [lindex $code 0] || "socket" != [lindex $code 1]} { error [dict get $dict error] \ [dict get $dict errorInfo] \ [dict get $dict errorCode] } } incr mytries -1 incr delay $delay ; if {20000 < $delay} {set delay 20000} if {"500" ne [dict get $dict httpstatus] || $mytries <= 0} { return $dict } if {![dict exists $myargs -blocking] || [dict get $myargs -blocking]} { after $delay } else { set timer [bgvar] after $delay [list set $timer 1] vwait $timer unset $timer } } } # Internal. Maybe throw an HTTP error if httpstatus not in 200 range. proc S3::throwhttp {dict} { set hs [dict get $dict httpstatus] if {![string match "2??" $hs]} { error "S3 received non-OK HTTP result of $hs" "" \ [list S3 remote $hs $dict] } } # Public. Returns the list of buckets for this user. proc S3::ListAllMyBuckets {args} { checkinit ; # I know this gets done later. set myargs [S3::parseargs1 $args {-blocking -parse-xml -result-type}] if {![dict exists $myargs -result-type]} { dict set myargs -result-type names } if {![dict exists $myargs -blocking]} { dict set myargs -blocking true } set restype [dict get $myargs -result-type] if {$restype eq "REST" && [dict exists $myargs -parse-xml]} { error "Do not use REST with -parse-xml" "" \ [list S3 usage -parse-xml $args] } if {![dict exists $myargs -parse-xml]} { # We need to fetch the results. set req [dict create verb GET resource /] set dict [S3::maybebackground $req $myargs] if {$restype eq "REST"} { return $dict ; #we're done! } S3::throwhttp $dict ; #make sure it worked. set xml [dict get $dict outbody] } else { set xml [dict get $myargs -parse-xml] } # Here, we either already returned the dict, or the XML is in "xml". if {$restype eq "xml"} {return $xml} if {[catch {set pxml [::xsxp::parse $xml]}]} { error "S3 invalid XML structure" "" [list S3 usage xml $xml] } if {$restype eq "pxml"} {return $pxml} if {$restype eq "dict" || $restype eq "names"} { set buckets [::xsxp::fetch $pxml "Buckets" %CHILDREN] set names {} ; set dates {} foreach bucket $buckets { lappend names [::xsxp::fetch $bucket "Name" %PCDATA] lappend dates [::xsxp::fetch $bucket "CreationDate" %PCDATA] } if {$restype eq "names"} { return $names } else { return [dict create \ Owner/ID [::xsxp::fetch $pxml "Owner/ID" %PCDATA] \ Owner/DisplayName \ [::xsxp::fetch $pxml "Owner/DisplayName" %PCDATA] \ Bucket/Name $names Bucket/Date $dates \ ] } } if {$restype eq "owner"} { return [list [::xsxp::fetch $pxml Owner/ID %PCDATA] \ [::xsxp::fetch $pxml Owner/DisplayName %PCDATA] ] } error "ListAllMyBuckets requires -result-type to be REST, xml, pxml, dict, owner, or names" "" [list S3 usage -result-type $args] } # Public. Create a bucket. proc S3::PutBucket {args} { checkinit set myargs [S3::parseargs1 $args {-blocking -bucket -acl}] if {![dict exists $myargs -acl]} { dict set myargs -acl [S3::Configure -default-acl] } if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict exists $myargs -bucket]} { error "PutBucket requires -bucket" "" [list S3 usage -bucket $args] } set req [dict create verb PUT resource /[dict get $myargs -bucket]] if {[dict exists $myargs -acl]} { dict set req headers [list x-amz-acl [dict get $myargs -acl]] } set dict [S3::maybebackground $req $myargs] S3::throwhttp $dict return "" ; # until we decide what to return. } # Public. Delete a bucket. proc S3::DeleteBucket {args} { checkinit set myargs [S3::parseargs1 $args {-blocking -bucket}] if {![dict exists $myargs -bucket]} { error "DeleteBucket requires -bucket" "" [list S3 usage -bucket $args] } dict set myargs -bucket [string trim [dict get $args -bucket] "/ "] set req [dict create verb DELETE resource /[dict get $myargs -bucket]] set dict [S3::maybebackground $req $myargs] S3::throwhttp $dict return "" ; # until we decide what to return. } # Internal. Suck out the one and only answer from the list, if needed. proc S3::firstif {list myargs} { if {[dict exists $myargs -max-keys]} { return [lindex $list 0] } else { return $list } } # Public. Get the list of resources within a bucket. proc S3::GetBucket {args} { checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -parse-xml -max-keys -result-type -prefix -delimiter -TEST }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict get $myargs -bucket]} { error "GetBucket requires -bucket" "" [list S3 usage -bucket $args] } if {[dict get $myargs -bucket] eq ""} { error "GetBucket requires -bucket nonempty" "" \ [list S3 usage -bucket $args] } if {![dict exists $myargs -result-type]} { dict set myargs -result-type names } if {[dict get $myargs -result-type] eq "REST" && \ [dict exists $myargs "-parse-xml"]} { error "GetBucket can't have -parse-xml with REST result" "" \ [list S3 usage -parse-xml $args] } set req [dict create verb GET resource /[dict get $myargs -bucket]] set parameters {} # Now, just to make test cases easier... if {[dict exists $myargs -TEST]} { dict set parameters max-keys [dict get $myargs -TEST] } # Back to your regularly scheduled argument parsing if {[dict exists $myargs -max-keys]} { dict set parameters max-keys [dict get $myargs -max-keys] } if {[dict exists $myargs -prefix]} { set p [dict get $myargs -prefix] if {[string match "/*" $p]} { set p [string range $p 1 end] } dict set parameters prefix $p } if {[dict exists $myargs -delimiter]} { dict set parameters delimiter [dict get $myargs -delimiter] } set nextmarker0 {} ; # We use this for -result-type dict. if {![dict exists $myargs -parse-xml]} { # Go fetch answers. # Current xaction in "0" vars, with accumulation in "L" vars. # Ultimate result of this loop is $RESTL, a list of REST results. set RESTL [list] while {1} { set req0 $req ; dict set req0 parameters $parameters set REST0 [S3::maybebackground $req0 $myargs] S3::throwhttp $REST0 lappend RESTL $REST0 if {[dict exists $myargs -max-keys]} { # We were given a limit, so just return the answer. break } set pxml0 [::xsxp::parse [dict get $REST0 outbody]] set trunc0 [expr "true" eq \ [::xsxp::fetch $pxml0 IsTruncated %PCDATA]] if {!$trunc0} { # We've retrieved the final block, so go parse it. set nextmarker0 "" ; # For later. break } # Find the highest contents entry. (Would have been # easier if Amazon always supplied NextMarker.) set nextmarker0 {} foreach {only tag} {Contents Key CommonPrefixes Prefix} { set only0 [::xsxp::only $pxml0 $only] if {0 < [llength $only0]} { set k0 [::xsxp::fetch [lindex $only0 end] $tag %PCDATA] if {[string compare $nextmarker0 $k0] < 0} { set nextmarker0 $k0 } } } if {$nextmarker0 eq ""} {error "Internal Error in S3 library"} # Here we have the next marker, so fetch the next REST dict set parameters marker $nextmarker0 # Note - $nextmarker0 is used way down below again! } # OK, at this point, the caller did not provide the xml via -parse-xml # And now we have a list of REST results. So let's process. if {[dict get $myargs -result-type] eq "REST"} { return [S3::firstif $RESTL $myargs] } set xmlL [list] foreach entry $RESTL { lappend xmlL [dict get $entry outbody] } unset RESTL ; # just to save memory } else { # Well, we've parsed out the XML from the REST, # so we're ready for -parse-xml set xmlL [list [dict get $myargs -parse-xml]] } if {[dict get $myargs -result-type] eq "xml"} { return [S3::firstif $xmlL $myargs] } set pxmlL [list] foreach xml $xmlL { lappend pxmlL [::xsxp::parse $xml] } unset xmlL if {[dict get $myargs -result-type] eq "pxml"} { return [S3::firstif $pxmlL $myargs] } # Here, for result types of "names" and "dict", # we need to actually parse out all the results. if {[dict get $myargs -result-type] eq "names"} { # The easy one. set names [list] foreach pxml $pxmlL { set con0 [::xsxp::only $pxml Contents] set con1 [::xsxp::only $pxml CommonPrefixes] lappend names {*}[concat [::xsxp::fetchall $con0 Key %PCDATA] \ [::xsxp::fetchall $con1 Prefix %PCDATA]] } return [lsort $names] } elseif {[dict get $myargs -result-type] eq "dict"} { # The harder one. set last0 [lindex $pxmlL end] set res [dict create] foreach thing {Name Prefix Marker MaxKeys IsTruncated} { dict set res $thing [::xsxp::fetch $last0 $thing %PCDATA?] } dict set res NextMarker $nextmarker0 ; # From way up above. set Prefix [list] set names {Key LastModified ETag Size Owner/ID Owner/DisplayName StorageClass} foreach name $names {set $name [list]} foreach pxml $pxmlL { foreach tag [::xsxp::only $pxml CommonPrefixes] { lappend Prefix [::xsxp::fetch $tag Prefix %PCDATA] } foreach tag [::xsxp::only $pxml Contents] { foreach name $names { lappend $name [::xsxp::fetch $tag $name %PCDATA] } } } dict set res CommonPrefixes/Prefix $Prefix foreach name $names {dict set res $name [set $name]} return $res } else { # The hardest one ;-) error "GetBucket Invalid result type, must be REST, xml, pxml, names, or dict" "" [list S3 usage -result-type $args] } } # Internal. Compare a resource to a file. # Returns 1 if they're different, 0 if they're the same. # Note that using If-Modified-Since and/or If-Match,If-None-Match # might wind up being more efficient than pulling the head # and checking. However, this allows for slop, checking both # the etag and the date, only generating local etag if the # date and length indicate they're the same, and so on. # Direction is G or P for Get or Put. # Assumes the source always exists. Obviously, Get and Put will throw if not, # but not because of this. proc S3::compare {myargs direction} { variable config global errorInfo set compare [dict get $myargs -compare] if {$compare ni {always never exists missing newer date checksum different}} { error "-compare must be always, never, exists, missing, newer, date, checksum, or different" "" \ [list S3 usage -compare $myargs] } if {"never" eq $compare} {return 0} if {"always" eq $compare} {return 1} if {[dict exists $myargs -file] && [file exists [dict get $myargs -file]]} { set local_exists 1 } else { set local_exists 0 } # Avoid hitting S3 if we don't need to. if {$direction eq "G" && "exists" eq $compare} {return $local_exists} if {$direction eq "G" && "missing" eq $compare} { return [expr !$local_exists] } # We need to get the headers from the resource. set req [dict create \ resource /[dict get $myargs -bucket]/[dict get $myargs -resource] \ verb HEAD ] set res [S3::maybebackground $req $myargs] set httpstatus [dict get $res httpstatus] if {"404" eq $httpstatus} { set remote_exists 0 } elseif {[string match "2??" $httpstatus]} { set remote_exists 1 } else { error "S3: Neither 404 or 2xx on conditional compare" "" \ [list S3 remote $httpstatus $res] } if {$direction eq "P"} { if {"exists" eq $compare} {return $remote_exists} if {"missing" eq $compare} {return [expr {!$remote_exists}]} if {!$remote_exists} {return 1} } elseif {$direction eq "G"} { # Actually already handled above, but it never hurts... if {"exists" eq $compare} {return $local_exists} if {"missing" eq $compare} {return [expr {!$local_exists}]} } set outheaders [dict get $res outheaders] if {[dict exists $outheaders content-length]} { set remote_length [dict get $outheaders content-length] } else { set remote_length -1 } if {[dict exists $outheaders etag]} { set remote_etag [string tolower \ [string trim [dict get $outheaders etag] \"]] } else { set remote_etag "YYY" } if {[dict exists $outheaders last-modified]} { set remote_date [clock scan [dict get $outheaders last-modified]] } else { set remote_date -1 } if {[dict exists $myargs -content]} { # Probably should work this out better... #set local_length [string length [encoding convert-to utf-8 \ #[dict get $myargs -content]]] set local_length [string length [dict get $myargs -content]] } elseif {$local_exists} { if {[catch {file size [dict get $myargs -file]} local_length]} { error "S3: Couldn't stat [dict get $myargs -file]" "" \ [list S3 local $errorInfo] } } else { set local_length -2 } if {[dict exists $myargs -content]} { set local_date [clock seconds] } elseif {$local_exists} { set local_date [file mtime [dict get $myargs -file]] # Shouldn't throw, since [file size] worked. } else { set local_date -2 } if {$direction eq "P"} { if {"newer" eq $compare} { if {$remote_date < $local_date - [dict get $config -slop-seconds]} { return 1 ; # Yes, local is newer } else { return 0 ; # Older, or the same } } } elseif {$direction eq "G"} { if {"newer" eq $compare} { if {$local_date < $remote_date - [dict get $config -slop-seconds]} { return 1 ; # Yes, remote is later. } else { return 0 ; # Local is older or same. } } } if {[dict get $config -slop-seconds] <= abs($local_date - $remote_date)} { set date_diff 1 ; # Difference is greater } else { set date_diff 0 ; # Difference negligible } if {"date" eq $compare} {return $date_diff} if {"different" eq $compare && [dict exists $myargs -file] && $date_diff} { return 1 } # Date's the same, but we're also interested in content, so check the rest # Only others to handle are checksum and different-with-matching-dates if {$local_length != $remote_length} {return 1} ; #easy quick case if {[dict exists $myargs -file] && $local_exists} { if {[catch { # Maybe deal with making this backgroundable too? set local_etag [string tolower \ [::md5::md5 -hex -filename [dict get $myargs -file]]] } caught]} { # Maybe you can stat but not read it? error "S3 could not hash file" "" \ [list S3 local [dict get $myargs -file] $errorInfo] } } elseif {[dict exists $myargs -content]} { set local_etag [string tolower \ [string tolower [::md5::md5 -hex [dict get $myargs -content]]]] } else { set local_etag "XXX" } # puts "local: $local_etag\nremote: $remote_etag" if {$local_etag eq $remote_etag} {return 0} {return 1} } # Internal. Calculates the ACL based on file permissions. proc S3::calcacl {myargs} { # How would one work this under Windows, then? # Silly way: invoke [exec cacls $filename], # parse the result looking for Everyone:F or Everyone:R # Messy security if someone replaces the cacls.exe or something. error "S3 Not Yet Implemented" "" [list S3 notyet calcacl $myargs] set result [S3::Configure -default-acl] catch { set chmod [file attributes [dict get $myargs -file] -permissions] set chmod [expr {$chmod & 6}] if {$chmod == 0} {set result private} if {$chmod == 2} {set result public-write} if {$chmod == 6} {set result public-read-write} } } # Public. Put a resource into a bucket. proc S3::Put {args} { checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -file -content -resource -acl -content-type -x-amz-meta-* -compare }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict get $myargs -bucket]} { error "Put requires -bucket" "" [list S3 usage -bucket $args] } if {![dict exists $myargs -blocking]} { dict set myargs -blocking true } if {![dict exists $myargs -file] && ![dict exists $myargs -content]} { error "Put requires -file or -content" "" [list S3 usage -file $args] } if {[dict exists $myargs -file] && [dict exists $myargs -content]} { error "Put says -file, -content mutually exclusive" "" [list S3 usage -file $args] } if {![dict exists $myargs -resource]} { error "Put requires -resource" "" [list S3 usage -resource $args] } if {![dict exists $myargs -compare]} { dict set myargs -compare [S3::Configure -default-compare] } if {![dict exists $myargs -acl] && "" ne [S3::Configure -default-acl]} { dict set myargs -acl [S3::Configure -default-acl] } if {[dict exists $myargs -file] && \ "never" ne [dict get $myargs -compare] && \ ![file exists [dict get $myargs -file]]} { error "Put -file doesn't exist: [dict get $myargs -file]" \ "" [list S3 usage -file $args] } # Clean up bucket, and take one leading slash (if any) off resource. if {[string match "/*" [dict get $myargs -resource]]} { dict set myargs -resource \ [string range [dict get $myargs -resource] 1 end] } # See if we need to copy it. set comp [S3::compare $myargs P] if {!$comp} {return 0} ; # skip it, then. # Oookeydookey. At this point, we're actually going to send # the file, so all we need to do is build the request array. set req [dict create verb PUT \ resource /[dict get $myargs -bucket]/[dict get $myargs -resource]] if {[dict exists $myargs -file]} { dict set req infile [dict get $myargs -file] } else { dict set req inbody [dict get $myargs -content] } if {[dict exists $myargs -content-type]} { dict set req content-type [dict get $myargs -content-type] } set headers {} foreach xhead [dict keys $myargs -x-amz-meta-*] { dict set headers [string range $xhead 1 end] [dict get $myargs $xhead] } set xmlacl "" ; # For calc and keep if {[dict exists $myargs -acl]} { if {[dict get $myargs -acl] eq "calc"} { # We could make this more complicated by # assigning it to xmlacl after building it. dict set myargs -acl [S3::calcacl $myargs] } elseif {[dict get $myargs -acl] eq "keep"} { dict set myargs -acl [S3::Configure -default-acl] catch { set xmlacl [S3::GetAcl \ -bucket [dict get $myargs -bucket] \ -resource [dict get $myargs -resource] \ -blocking [dict get $myargs -blocking] \ -result-type xml] } } dict set headers x-amz-acl [dict get $myargs -acl] } dict set req headers $headers # That should do it. set res [S3::maybebackground $req $myargs] S3::throwhttp $res if {"<" == [string index $xmlacl 0]} { # Set the saved ACL back on the new object S3::PutAcl \ -bucket [dict get $myargs -bucket] \ -resource [dict get $myargs -resource] \ -blocking [dict get $myargs -blocking] \ -acl $xmlacl } return 1 ; # Yep, we copied it! } # Public. Get a resource from a bucket. proc S3::Get {args} { global errorCode checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -file -content -resource -timestamp -headers -compare }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict get $myargs -bucket]} { error "Get requires -bucket" "" [list S3 usage -bucket $args] } if {![dict exists $myargs -file] && ![dict exists $myargs -content]} { error "Get requires -file or -content" "" [list S3 usage -file $args] } if {[dict exists $myargs -file] && [dict exists $myargs -content]} { error "Get says -file, -content mutually exclusive" "" [list S3 usage -file $args] } if {![dict exists $myargs -resource]} { error "Get requires -resource" "" [list S3 usage -resource $args] } if {![dict exists $myargs -compare]} { dict set myargs -compare [S3::Configure -default-compare] } # Clean up bucket, and take one leading slash (if any) off resource. if {[string match "/*" [dict get $myargs -resource]]} { dict set myargs -resource \ [string range [dict get $myargs -resource] 1 end] } # See if we need to copy it. if {"never" eq [dict get $myargs -compare]} {return 0} if {[dict exists $myargs -content]} { set comp 1 } else { set comp [S3::compare $myargs G] } if {!$comp} {return 0} ; # skip it, then. # Oookeydookey. At this point, we're actually going to fetch # the file, so all we need to do is build the request array. set req [dict create verb GET \ resource /[dict get $myargs -bucket]/[dict get $myargs -resource]] if {[dict exists $myargs -file]} { set pre_exists [file exists [dict get $myargs -file]] if {[catch { set x [open [dict get $myargs -file] w] fconfigure $x -translation binary -encoding binary } caught]} { error "Get could not create file [dict get $myargs -file]" "" \ [list S3 local -file $errorCode] } dict set req outchan $x } # That should do it. set res [S3::maybebackground $req $myargs] if {[dict exists $req outchan]} { catch {close [dict get $req outchan]} if {![string match "2??" [dict get $res httpstatus]] && !$pre_exists} { catch {file delete -force -- [dict get $myargs -file]} } } S3::throwhttp $res if {[dict exists $myargs -headers]} { uplevel 1 \ [list set [dict get $myargs -headers] [dict get $res outheaders]] } if {[dict exists $myargs -content]} { uplevel 1 \ [list set [dict get $myargs -content] [dict get $res outbody]] } if {[dict exists $myargs -timestamp] && [dict exists $myargs -file]} { if {"aws" eq [dict get $myargs -timestamp]} { catch { set t [dict get $res outheaders last-modified] set t [clock scan $t -gmt true] file mtime [dict get $myargs -file] $t } } } return 1 ; # Yep, we copied it! } # Public. Get information about a resource in a bucket. proc S3::Head {args} { global errorCode checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -resource -headers -dict -status }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict get $myargs -bucket]} { error "Head requires -bucket" "" [list S3 usage -bucket $args] } if {![dict exists $myargs -resource]} { error "Head requires -resource" "" [list S3 usage -resource $args] } # Clean up bucket, and take one leading slash (if any) off resource. if {[string match "/*" [dict get $myargs -resource]]} { dict set myargs -resource \ [string range [dict get $myargs -resource] 1 end] } set req [dict create verb HEAD \ resource /[dict get $myargs -bucket]/[dict get $myargs -resource]] set res [S3::maybebackground $req $myargs] if {[dict exists $myargs -dict]} { uplevel 1 \ [list set [dict get $myargs -dict] $res] } if {[dict exists $myargs -headers]} { uplevel 1 \ [list set [dict get $myargs -headers] [dict get $res outheaders]] } if {[dict exists $myargs -status]} { set x [list [dict get $res httpstatus] [dict get $res httpmessage]] uplevel 1 \ [list set [dict get $myargs -status] $x] } return [string match "2??" [dict get $res httpstatus]] } # Public. Get the full ACL from an object and parse it into something useful. proc S3::GetAcl {args} { global errorCode checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -resource -result-type -parse-xml }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {![dict exists $myargs -result-type]} { dict set myargs -result-type "dict" } set restype [dict get $myargs -result-type] if {$restype eq "REST" && [dict exists $myargs -parse-xml]} { error "Do not use REST with -parse-xml" "" \ [list S3 usage -parse-xml $args] } if {![dict exists $myargs -parse-xml]} { # We need to fetch the results. if {"" eq [dict get $myargs -bucket]} { error "GetAcl requires -bucket" "" [list S3 usage -bucket $args] } if {![dict exists $myargs -resource]} { error "GetAcl requires -resource" "" [list S3 usage -resource $args] } # Clean up bucket, and take one leading slash (if any) off resource. if {[string match "/*" [dict get $myargs -resource]]} { dict set myargs -resource \ [string range [dict get $myargs -resource] 1 end] } set req [dict create verb GET \ resource /[dict get $myargs -bucket]/[dict get $myargs -resource] \ rtype acl] set dict [S3::maybebackground $req $myargs] if {$restype eq "REST"} { return $dict ; #we're done! } S3::throwhttp $dict ; #make sure it worked. set xml [dict get $dict outbody] } else { set xml [dict get $myargs -parse-xml] } if {[dict get $myargs -result-type] == "xml"} { return $xml } set pxml [xsxp::parse $xml] if {[dict get $myargs -result-type] == "pxml"} { return $pxml } if {[dict get $myargs -result-type] == "dict"} { array set resdict {} set owner [xsxp::fetch $pxml Owner/ID %PCDATA] set grants [xsxp::fetch $pxml AccessControlList %CHILDREN] foreach grant $grants { set perm [xsxp::fetch $grant Permission %PCDATA] set id "" catch {set id [xsxp::fetch $grant Grantee/ID %PCDATA]} if {$id == ""} { set id [xsxp::fetch $grant Grantee/URI %PCDATA] } lappend resdict($perm) $id } return [dict create owner $owner acl [array get resdict]] } error "GetAcl requires -result-type to be REST, xml, pxml or dict" "" [list S3 usage -result-type $args] } # Make one Grant thingie proc S3::engrant {who what} { if {$who == "AuthenticatedUsers" || $who == "AllUsers"} { set who http://acs.amazonaws.com/groups/global/$who } if {-1 != [string first "//" $who]} { set type Group ; set tag URI } elseif {-1 != [string first "@" $who]} { set type AmazonCustomerByEmail ; set tag EmailAddress } else { set type CanonicalUser ; set tag ID } set who [string map {< < > > & &} $who] set what [string toupper $what] set xml "<$tag>$who" append xml "$what" return $xml } # Make the owner header proc S3::enowner {owner} { return "$owner" return "\n$owner" } proc S3::endacl {} { return "\n" } # Public. Set the ACL on an existing object. proc S3::PutAcl {args} { global errorCode checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -resource -acl -owner }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict get $myargs -bucket]} { error "PutAcl requires -bucket" "" [list S3 usage -bucket $args] } if {![dict exists $myargs -resource]} { error "PutAcl requires -resource" "" [list S3 usage -resource $args] } if {![dict exists $myargs -acl]} { dict set myargs -acl [S3::Configure -default-acl] } dict set myargs -acl [string trim [dict get $myargs -acl]] if {[dict get $myargs -acl] == ""} { dict set myargs -acl [S3::Configure -default-acl] } if {[dict get $myargs -acl] == ""} { error "PutAcl requires -acl" "" [list D3 usage -resource $args] } # Clean up bucket, and take one leading slash (if any) off resource. if {[string match "/*" [dict get $myargs -resource]]} { dict set myargs -resource \ [string range [dict get $myargs -resource] 1 end] } # Now, figure out the XML to send. set acl [dict get $myargs -acl] set owner "" if {"<" != [string index $acl 0] && ![dict exists $myargs -owner]} { # Grab the owner off the resource set req [dict create verb GET \ resource /[dict get $myargs -bucket]/[dict get $myargs -resource] \ rtype acl] set dict [S3::maybebackground $req $myargs] S3::throwhttp $dict ; #make sure it worked. set xml [dict get $dict outbody] set pxml [xsxp::parse $xml] set owner [xsxp::fetch $pxml Owner/ID %PCDATA] } if {[dict exists $myargs -owner]} { set owner [dict get $myargs -owner] } set xml [enowner $owner] if {"" == $acl || "private" == $acl} { append xml [engrant $owner FULL_CONTROL] append xml [endacl] } elseif {"public-read" == $acl} { append xml [engrant $owner FULL_CONTROL] append xml [engrant AllUsers READ] append xml [endacl] } elseif {"public-read-write" == $acl} { append xml [engrant $owner FULL_CONTROL] append xml [engrant AllUsers READ] append xml [engrant AllUsers WRITE] append xml [endacl] } elseif {"authenticated-read" == $acl} { append xml [engrant $owner FULL_CONTROL] append xml [engrant AuthenticatedUsers READ] append xml [endacl] } elseif {"<" == [string index $acl 0]} { set xml $acl } elseif {[llength $acl] % 2 != 0} { error "S3::PutAcl -acl must be xml, private, public-read, public-read-write, authenticated-read, or a dictionary" \ "" [list S3 usage -acl $acl] } else { # ACL in permission/ID-list format. if {[dict exists $acl owner] && [dict exists $acl acl]} { set xml [S3::enowner [dict get $acl owner]] set acl [dict get $acl acl] } foreach perm {FULL_CONTROL READ READ_ACP WRITE WRITE_ACP} { if {[dict exists $acl $perm]} { foreach id [dict get $acl $perm] { append xml [engrant $id $perm] } } } append xml [endacl] } set req [dict create verb PUT \ resource /[dict get $myargs -bucket]/[dict get $myargs -resource] \ inbody $xml \ rtype acl] set res [S3::maybebackground $req $myargs] S3::throwhttp $res ; #make sure it worked. return $xml } # Public. Delete a resource from a bucket. proc S3::Delete {args} { global errorCode checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -resource -status }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict get $myargs -bucket]} { error "Delete requires -bucket" "" [list S3 usage -bucket $args] } if {![dict exists $myargs -resource]} { error "Delete requires -resource" "" [list S3 usage -resource $args] } # Clean up bucket, and take one leading slash (if any) off resource. if {[string match "/*" [dict get $myargs -resource]]} { dict set myargs -resource \ [string range [dict get $myargs -resource] 1 end] } set req [dict create verb DELETE \ resource /[dict get $myargs -bucket]/[dict get $myargs -resource]] set res [S3::maybebackground $req $myargs] if {[dict exists $myargs -status]} { set x [list [dict get $res httpstatus] [dict get $res httpmessage]] uplevel 1 \ [list set [dict get $myargs -status] $x] } return [string match "2??" [dict get $res httpstatus]] } # Some helper routines for Push, Pull, and Sync # Internal. Filter for fileutil::find. proc S3::findfilter {dirs name} { # In particular, skip links, devices, etc. if {$dirs} { return [expr {[file isdirectory $name] || [file isfile $name]}] } else { return [file isfile $name] } } # Internal. Get list of local files, appropriately trimmed. proc S3::getLocal {root dirs} { # Thanks to Michael Cleverly for this first line... set base [file normalize [file join [pwd] $root]] if {![string match "*/" $base]} { set base $base/ } set files {} ; set bl [string length $base] foreach file [fileutil::find $base [list S3::findfilter $dirs]] { if {[file isdirectory $file]} { lappend files [string range $file $bl end]/ } else { lappend files [string range $file $bl end] } } set files [lsort $files] # At this point, $files is a sorted list of all the local files, # with a trailing / on any directories included in the list. return $files } # Internal. Get list of remote resources, appropriately trimmed. proc S3::getRemote {bucket prefix blocking} { set prefix [string trim $prefix " /"] if {0 != [string length $prefix]} {append prefix /} set res [S3::GetBucket -bucket $bucket -prefix $prefix \ -result-type names -blocking $blocking] set names {} ; set pl [string length $prefix] foreach name $res { lappend names [string range $name $pl end] } return [lsort $names] } # Internal. Create any directories we need to put the file in place. proc S3::makeDirs {directory suffix} { set sofar {} set nodes [split $suffix /] set nodes [lrange $nodes 0 end-1] foreach node $nodes { lappend sofar $node set tocheck [file join $directory {*}$sofar] if {![file exists $tocheck]} { catch {file mkdir $tocheck} } } } # Internal. Default progress monitor for push, pull, toss. proc S3::ignore {args} {} ; # default progress monitor # Internal. For development and testing. Progress monitor. proc S3::printargs {args} {puts $args} ; # For testing. # Public. Send a local directory tree to S3. proc S3::Push {args} { uplevel #0 package require fileutil global errorCode errorInfo checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -prefix -directory -compare -x-amz-meta-* -acl -delete -error -progress }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict get $myargs -bucket]} { error "Push requires -bucket" "" [list S3 usage -bucket $args] } if {![dict exists $myargs -directory]} { error "Push requires -directory" "" [list S3 usage -directory $args] } # Set default values. set defaults " -acl \"[S3::Configure -default-acl]\" -compare [S3::Configure -default-compare] -prefix {} -delete 0 -error continue -progress ::S3::ignore -blocking 1" foreach {key val} $defaults { if {![dict exists $myargs $key]} {dict set myargs $key $val} } # Pull out arguments for convenience foreach i {progress prefix directory bucket blocking} { set $i [dict get $myargs -$i] } set prefix [string trimright $prefix /] set meta [dict filter $myargs key x-amz-meta-*] # We're readdy to roll here. uplevel 1 [list {*}$progress args $myargs] if {[catch { set local [S3::getLocal $directory 0] } caught]} { error "Push could not walk local directory - $caught" \ $errorInfo $errorCode } uplevel 1 [list {*}$progress local $local] if {[catch { set remote [S3::getRemote $bucket $prefix $blocking] } caught]} { error "Push could not walk remote directory - $caught" \ $errorInfo $errorCode } uplevel 1 [list {*}$progress remote $remote] set result [dict create] set result0 [dict create \ filescopied 0 bytescopied 0 compareskipped 0 \ errorskipped 0 filesdeleted 0 filesnotdeleted 0] foreach suffix $local { uplevel 1 [list {*}$progress copy $suffix start] set err [catch { S3::Put -bucket $bucket -blocking $blocking \ -file [file join $directory $suffix] \ -resource $prefix/$suffix \ -acl [dict get $myargs -acl] \ {*}$meta \ -compare [dict get $myargs -compare]} caught] if {$err} { uplevel 1 [list {*}$progress copy $suffix $errorCode] dict incr result0 errorskipped dict set result $suffix $errorCode if {[dict get $myargs -error] eq "throw"} { error "Push failed to Put - $caught" $errorInfo $errorCode } elseif {[dict get $myargs -error] eq "break"} { break } } else { if {$caught} { uplevel 1 [list {*}$progress copy $suffix copied] dict incr result0 filescopied dict incr result0 bytescopied \ [file size [file join $directory $suffix]] dict set result $suffix copied } else { uplevel 1 [list {*}$progress copy $suffix skipped] dict incr result0 compareskipped dict set result $suffix skipped } } } # Now do deletes, if so desired if {[dict get $myargs -delete]} { foreach suffix $remote { if {$suffix ni $local} { set err [catch { S3::Delete -bucket $bucket -blocking $blocking \ -resource $prefix/$suffix } caught] if {$err} { uplevel 1 [list {*}$progress delete $suffix $errorCode] dict incr result0 filesnotdeleted dict set result $suffix notdeleted } else { uplevel 1 [list {*}$progress delete $suffix {}] dict incr result0 filesdeleted dict set result $suffix deleted } } } } dict set result {} $result0 uplevel 1 [list {*}$progress finished $result] return $result } # Public. Fetch a portion of a remote bucket into a local directory tree. proc S3::Pull {args} { # This is waaaay to similar to Push for comfort. # Fold it up later. uplevel #0 package require fileutil global errorCode errorInfo checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -prefix -directory -compare -timestamp -delete -error -progress }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict get $myargs -bucket]} { error "Pull requires -bucket" "" [list S3 usage -bucket $args] } if {![dict exists $myargs -directory]} { error "Pull requires -directory" "" [list S3 usage -directory $args] } # Set default values. set defaults " -timestamp now -compare [S3::Configure -default-compare] -prefix {} -delete 0 -error continue -progress ::S3::ignore -blocking 1" foreach {key val} $defaults { if {![dict exists $myargs $key]} {dict set myargs $key $val} } # Pull out arguments for convenience foreach i {progress prefix directory bucket blocking} { set $i [dict get $myargs -$i] } set prefix [string trimright $prefix /] # We're readdy to roll here. uplevel 1 [list {*}$progress args $myargs] if {[catch { set local [S3::getLocal $directory 1] } caught]} { error "Pull could not walk local directory - $caught" \ $errorInfo $errorCode } uplevel 1 [list {*}$progress local $local] if {[catch { set remote [S3::getRemote $bucket $prefix $blocking] } caught]} { error "Pull could not walk remote directory - $caught" \ $errorInfo $errorCode } uplevel 1 [list {*}$progress remote $remote] set result [dict create] set result0 [dict create \ filescopied 0 bytescopied 0 compareskipped 0 \ errorskipped 0 filesdeleted 0 filesnotdeleted 0] foreach suffix $remote { uplevel 1 [list {*}$progress copy $suffix start] set err [catch { S3::makeDirs $directory $suffix S3::Get -bucket $bucket -blocking $blocking \ -file [file join $directory $suffix] \ -resource $prefix/$suffix \ -timestamp [dict get $myargs -timestamp] \ -compare [dict get $myargs -compare]} caught] if {$err} { uplevel 1 [list {*}$progress copy $suffix $errorCode] dict incr result0 errorskipped dict set result $suffix $errorCode if {[dict get $myargs -error] eq "throw"} { error "Pull failed to Get - $caught" $errorInfo $errorCode } elseif {[dict get $myargs -error] eq "break"} { break } } else { if {$caught} { uplevel 1 [list {*}$progress copy $suffix copied] dict incr result0 filescopied dict incr result0 bytescopied \ [file size [file join $directory $suffix]] dict set result $suffix copied } else { uplevel 1 [list {*}$progress copy $suffix skipped] dict incr result0 compareskipped dict set result $suffix skipped } } } # Now do deletes, if so desired if {[dict get $myargs -delete]} { foreach suffix [lsort -decreasing $local] { # Note, decreasing because we delete empty dirs if {[string match "*/" $suffix]} { set f [file join $directory $suffix] catch {file delete -- $f} if {![file exists $f]} { uplevel 1 [list {*}$progress delete $suffix {}] dict set result $suffix deleted dict incr result0 filesdeleted } } elseif {$suffix ni $remote} { set err [catch { file delete [file join $directory $suffix] } caught] if {$err} { uplevel 1 [list {*}$progress delete $suffix $errorCode] dict incr result0 filesnotdeleted dict set result $suffix notdeleted } else { uplevel 1 [list {*}$progress delete $suffix {}] dict incr result0 filesdeleted dict set result $suffix deleted } } } } dict set result {} $result0 uplevel 1 [list {*}$progress finished $result] return $result } # Public. Delete a collection of resources with the same prefix. proc S3::Toss {args} { # This is waaaay to similar to Push for comfort. # Fold it up later. global errorCode errorInfo checkinit set myargs [S3::parseargs1 $args { -bucket -blocking -prefix -error -progress }] if {![dict exists $myargs -bucket]} { dict set myargs -bucket [S3::Configure -default-bucket] } dict set myargs -bucket [string trim [dict get $myargs -bucket] "/ "] if {"" eq [dict get $myargs -bucket]} { error "Toss requires -bucket" "" [list S3 usage -bucket $args] } if {![dict exists $myargs -prefix]} { error "Toss requires -prefix" "" [list S3 usage -directory $args] } # Set default values. set defaults "-error continue -progress ::S3::ignore -blocking 1" foreach {key val} $defaults { if {![dict exists $myargs $key]} {dict set myargs $key $val} } # Pull out arguments for convenience foreach i {progress prefix bucket blocking} { set $i [dict get $myargs -$i] } set prefix [string trimright $prefix /] # We're readdy to roll here. uplevel 1 [list {*}$progress args $myargs] if {[catch { set remote [S3::getRemote $bucket $prefix $blocking] } caught]} { error "Toss could not walk remote bucket - $caught" \ $errorInfo $errorCode } uplevel 1 [list {*}$progress remote $remote] set result [dict create] set result0 [dict create \ filescopied 0 bytescopied 0 compareskipped 0 \ errorskipped 0 filesdeleted 0 filesnotdeleted 0] # Now do deletes foreach suffix $remote { set err [catch { S3::Delete -bucket $bucket -blocking $blocking \ -resource $prefix/$suffix } caught] if {$err} { uplevel 1 [list {*}$progress delete $suffix $errorCode] dict incr result0 filesnotdeleted dict set result $suffix notdeleted } else { uplevel 1 [list {*}$progress delete $suffix {}] dict incr result0 filesdeleted dict set result $suffix deleted } } dict set result {} $result0 uplevel 1 [list {*}$progress finished $result] return $result }