blob: f81363f30cb4b8894bbd84b61f8eea1134cce3bb [file] [log] [blame]
# -- formbroker.tcl
#
# Form validation and sanitation tool. Developed starting from
# code initially donated by Karl Lehenbauer (Flightaware.com)
#
# Copyright 2017 The Rivet Team
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
namespace eval FormBroker {
variable form_definitions [dict create]
variable form_list [dict create]
variable string_quoting force_quote
variable form_count 0
#
# response_security_error - issue an error with errorCode
#
# set appropriate -- we expect the rivet error handler
# to catch this and do the right thing
#
proc response_security_error {type message} {
error $message "" [list RIVET SECURITY $type $message]
}
#
# force_response_integers - error if any of named vars in response doesn't exist
#
# or isn't an integer
#
proc force_response_integers {_response args} {
upvar $_response response
require_response_vars response {*}$args
foreach var $args {
if {![regexp {[0-9-]*} response($var)]} {
response_security_error NOT_INTEGER "illegal content in $var"
}
if {![scan $response($var) %d response($var)]} {
response_security_error NOT_INTEGER "illegal content in $var"
}
}
}
#
# force_response_integer_in_range - error if var in response isn't an integer
# or if it isn't in range
#
proc force_response_integer_in_range {_response var lowest highest} {
upvar $_response response
force_response_integers response $var
if {$response($var) < $lowest || $response($var) > $highest} {
response_security_error "OUT_OF_RANGE" "$var out of range"
}
}
# -- force_quote
#
proc force_quote {str} {
return "'$str'"
}
# -- force_sanitize_response_strings
proc force_sanitize_response_strings {_response args} { }
#
# force_quote_response_strings - sanitize and pg_quote all the specified strings in the array
#
proc force_quote_response_strings {_response args} {
upvar $_response response
force_sanitize_response_strings response {*}$args
foreach var $args {
set response($var) [$string_quoting $response($var)]
}
}
#
# -- force_quote_response_unfilteredstrings - rewrite named response
# elements pg_quoted
#
proc force_quote_response_unfilteredstrings {_response args} {
upvar $_response response
require_response_vars response {*}$args
foreach var $args {
set response($var) [$string_quoting $response($var)]
}
}
# -- base validators
proc validate_string {_var_d} {
upvar $_var_d var_d
set valid FB_OK
dict with var_d {
if {$bounds > 0} {
if {($nonempty == 1) && ($var == "")} {
set valid FB_EMPTY_STRING
} elseif {$constrain} {
set var [string range $var 0 $bounds-1]
} elseif {[string length $var] > $bounds} {
set valid FB_STRING_TOO_LONG
}
}
}
return $valid
}
# -- validate_integer
#
# integer validation checks whether
#
# 1- the representation *is* an integer
# 2- if buonds exist the value must be between [-bound,bound]
# 3- if the bounds is a list of 2 elements the value must
# be between them
#
# If needed the variable is constrained within the bounds.
#
proc validate_integer {_var_d} {
upvar $_var_d var_d
#puts "var_d: $var_d"
set valid FB_OK
dict with var_d {
if {![string is integer $var]} {
return NOT_INTEGER
}
if {[llength $bounds] == 2} {
::lassign $bounds min_v max_v
if {$constrain} {
set var [expr min($var,$max_v)]
set var [expr max($var,$min_v)]
set valid FB_OK
} elseif {($var > $max_v) || ($var < $min_v)} {
set valid FB_OUT_OF_BOUNDS
} else {
set valid FB_OK
}
} elseif {([llength $bounds] == 1) && ($bounds > 0)} {
if {$constrain} {
set var [expr min($bounds,$var)]
set var [expr max(-$bounds,$var)]
set valid FB_OK
} elseif {(abs($var) > $bounds)} {
set valid FB_OUT_OF_BOUNDS
} else {
set valid FB_OK
}
}
}
return $valid
}
proc validate_unsigned {_var_d} {
upvar $_var_d var_d
dict with var_d {
if {![string is integer $var]} {
return NOT_INTEGER
}
if {[llength $bounds] == 2} {
::lassign $bounds min_v max_v
if {$constrain} {
set var [expr min($var,$max_v)]
set var [expr max($var,$min_v)]
set valid FB_OK
} elseif {($var > $max_v) || ($var < $min_v)} {
set valid FB_OUT_OF_BOUNDS
} else {
set valid FB_OK
}
} elseif {([llength $bounds] == 1) && \
($bounds > 0)} {
if {$constrain} {
set var [expr max(0,$var)]
set var [expr min($bounds,$var)]
set valid FB_OK
} elseif {($var > $bounds) || ($var < 0)} {
set valid FB_OUT_OF_BOUNDS
} else {
set valid FB_OK
}
} else {
if {$constrain} {
set var [expr max(0,$var)]
set valid FB_OK
} elseif {$var < 0} {
set valid FB_OUT_OF_BOUNDS
} else {
set valid FB_OK
}
}
}
return $valid
}
proc validate_email {_var_d} {
upvar $_var_d var_d
dict with var_d {
if {[regexp -nocase {[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}} $var]} {
return FB_OK
} else {
return FB_INVALID_EMAIL
}
}
}
proc validate_boolean {_var_d} {
upvar $_var_d var_d
dict with var_d {
if {[string is boolean $var]} {
if {$constrain} {
set var [string is true $var]
}
return FB_OK
} else {
return FB_INVALID_BOOLEAN
}
}
}
proc validate_variable_representation {_var_d} {
upvar $_var_d var_d
variable form_definitions
set validator [dict get $var_d validator]
if {[info commands $validator] == ""} {
set validator ::FormBroker::validate_string
}
set validation [$validator var_d]
dict set var_d field_validation $validation
return [string match $validation FB_OK]
}
proc validate_var {form_name var_name var_value {force_quoting "-noforcequote"}} {
variable form_definitions
upvar $var_value value
set force_quote_var [string match $force_quoting "-forcequote"]
set variable_d [dict get $form_definitions $form_name $var_name]
dict set variable_d var $value
set valid [validate_variable_representation variable_d]
set value [dict get $variable_d var]
if {[dict get $variable_d force_quote] || $force_quote_var} {
set value [$string_quoting $value]
}
return $valid
}
# -- bounds_consistency
#
# During the form creation stage this method is called
# to correct possible inconsistencies with a field bounds
# definition
#
proc bounds_consistency {field_type _bounds} {
upvar $_bounds bounds
switch $field_type {
integer {
if {[llength $bounds] == 1} {
set bounds [list [expr -abs($bounds)] [expr abs($bounds)]]
} elseif {[llength $bounds] > 1} {
lassign $bounds l1 l2
set bounds [list [expr min($l1,$l2)] [expr max($l1,$l2)]]
} else {
set bounds 0
}
}
unsigned {
if {[llength $bounds] == 1} {
set bounds [list 0 [expr abs($bounds)]]
} elseif {[llength $bounds] > 1} {
lassign $bounds l1 l2
if {$l1 < 0} { set l1 0 }
if {$l2 < 0} { set l2 0 }
set bounds [list [expr min($l1,$l2)] [expr max($l1,$l2)]]
} else {
set bounds 0
}
}
}
}
# -- form_definition
#
# currently this call returns the dictionary
# of form field definitions. It's not meant to be
# used in regular development. It's supposed to be
# private to the FormBroker package
# and it may go away with future developments or
# change its interface and returned value
proc form_definition {form_name} {
variable form_definitions
return [dict get $form_definitions $form_name]
}
# -- validation_error
#
# returns the result of the last validation
# operation called on for this form.
#
proc validation_error {form_name} {
variable form_list
return [dict get $form_list $form_name form_validation]
}
# -- failing
#
# returns a list of variable-status pairs for each
# field in a form that did not validate
#
proc failing {form_name} {
set res {}
dict for {field field_d} [form_definition $form_name] {
dict with field_d {
if {$field_validation != "FB_OK"} {
lappend res $field $field_validation
}
}
}
return $res
}
# -- result
#
# accessor to the form field definitions. This procedure
# too is not (at least temporarily) to be called from
# outside the package
#
proc result {form_name form_field} {
variable form_definitions
return [dict get $form_definitions $form_name $form_field]
}
# --require_response_vars
#
# error if any of the specified are not in the response
#
proc require_response_vars {form_name _response} {
upvar $_response response
variable form_definitions
set missing_vars 0
dict for {var variable_d} [dict get $form_definitions $form_name] {
if {![info exists response($var)]} {
dict with form_definitions $form_name $var {
# if the variable was not in the response
# but a default was set then we copy this
# value in the variable descriptor and
# the response array as well
if {[info exists default]} {
set response($var) $default
set var $default
} else {
set field_validation MISSING_VAR
set missing_vars 1
}
}
}
}
if {$missing_vars} {
response_security_error MISSING_VAR \
"var $var not present in $_response"
}
}
# -- validate
#
#
proc validate { form_name args } {
variable form_definitions
variable form_list
variable string_quoting
set force_quote_vars 0
set arguments $args
if {[llength $arguments] == 0} {
error "missing required arguments"
} elseif {[llength $arguments] > 3} {
error "error calling validate, usage: validate ?-forcequote? response ?copy_response?"
}
while {[llength $arguments]} {
set arguments [::lassign $arguments a]
if {$a == "-forcequote"} {
set force_quote_vars 1
} elseif {![array exists response]} {
upvar $a response
} else {
upvar $a filtered_response
array set filtered_response {}
}
}
if {![array exists response]} {
error "error calling validate, usage: validate ?-forcequote? response ?copy_response?"
}
# we now go ahead validating the response variables
set form_valid true
set vars_to_validate [dict get $form_list $form_name vars]
if {[catch {
require_response_vars $form_name response
} er eopts]} {
#puts "$er $eopts"
dict set form_list $form_name form_validation FB_MISSING_VARS
return false
}
# field validation
dict with form_list $form_name {
set form_validation FB_OK
}
set form_d [dict get $form_definitions $form_name]
#puts "form_d: $form_d"
array unset response_a
dict for {var variable_d} $form_d {
dict set variable_d var $response($var)
if {[validate_variable_representation variable_d] == 0} {
dict set form_list $form_name form_validation FB_VALIDATION_ERROR
set form_valid false
} else {
# in case it was constrained we write the value back
# into the response array
if {[dict get $variable_d constrain]} {
set response_a($var) [dict get $variable_d var]
} else {
set response_a($var) $response($var)
}
if {[dict get $variable_d force_quote] || $force_quote_vars} {
set response_a($var) [$string_quoting [dict get $variable_d var]]
}
}
dict set form_definitions $form_name $var $variable_d
#puts "validated $var -> $variable_d"
}
# if 'validate' has been called with a filtered_response array
# we clean it up and proceed copying the variable values into it
if {[array exists filtered_response]} {
array unset filtered_response
array set filtered_response [array get response_a]
} else {
array set response [array get response_a]
}
return $form_valid
}
# -- response
#
#
proc response {form_name {resp_a response}} {
upvar $resp_a response
variable form_definitions
dict for {var_name var_d} [dict get $form_definitions $form_name] {
catch {unset var}
catch {unset default}
dict with var_d {
if {[info exists var]} {
set response($var_name) $var
} elseif {[info exists default]} {
set response($var_name) $default
}
}
}
}
# -- reset
#
#
proc reset {form_name} {
variable form_definitions
variable form_list
dict set form_list $form_name form_validation FB_OK
dict for {var_name var_d} [dict get $form_definitions $form_name] {
catch {dict unset var_d $var_name var}
}
}
# -- destroy
#
# this method is designed to be called
# by a 'trace unset' event on the variable
# keeping the form description object.
#
proc destroy {form_name args} {
variable form_definitions
variable form_list
dict unset form_definitions $form_name
dict unset form_list $form_name
namespace delete ::FormBroker::${form_name}
#puts "destroy of $form_name finished"
}
# -- create
#
# creates a form object starting from a list of element descriptors
#
# the procedure accept a list of single descriptors, being each
# descriptor a sub-list itself
#
# - field_name
# - type (string, integer, unsigned, email, base64)
# - a list of the following keywords and related values
#
# - bounds <value>
# - bounds [low high]
# - check_routine [validation routine]
# - length [max length]
#
proc create {args} {
variable form_definitions
variable form_list
variable form_count
variable string_quoting
set form_name "form${form_count}"
incr form_count
catch { namespace delete $form_name }
namespace eval $form_name {
foreach cmd { validate failing \
form_definition \
result validate_var \
destroy validation_error \
response reset } {
lappend cmdmap $cmd [list [namespace parent] $cmd [namespace tail [namespace current]]]
}
namespace ensemble create -map [dict create {*}$cmdmap]
unset cmdmap
unset cmd
}
dict set form_definitions $form_name [dict create]
dict set form_list $form_name [dict create vars {} \
form_validation FB_OK \
failing {} \
default "" \
quoting $string_quoting]
while {[llength $args]} {
set args [::lassign $args e]
if {$e == "-quoting"} {
dict with form_list $form_name {
set args [::lassign $args quoting]
if {[uplevel [list info proc $quoting]] == ""} {
error [list RIVET INVALID_QUOTING_PROC \
"Non existing quoting proc '$quoting'"]
}
set string_quoting $quoting
}
continue
}
# each variable (field) definition must start with the
# variable name and variable type. Every other variable
# specification argument can be listed in arbitrary order
# with the only constraint that argument values must follow
# an argument name. If an argument is specified multiple times
# the last definition overrides the former ones
set e [::lassign $e field_name field_type]
# the 'vars' dictionary field stores the
# order of form fields in which they are processed
# (in general this order would be destroyed by the Tcl's hash
# tables)
dict with form_list $form_name {::lappend vars $field_name}
# this test would handle the case of the most simple possible
# variable definition (just the variable name)
if {$field_type == ""} {
set field_type string
}
dict set form_definitions $form_name $field_name \
[list type $field_type \
bounds 0 \
constrain 0 \
validator [namespace current]::validate_string \
force_quote 0 \
nonempty 0 \
field_validation FB_OK]
dict with form_definitions $form_name $field_name {
switch $field_type {
integer {
set validator [namespace current]::validate_integer
}
unsigned {
set validator [namespace current]::validate_unsigned
}
email {
set validator [namespace current]::validate_email
}
boolean {
set validator [namespace current]::validate_boolean
}
string -
default {
set validator [namespace current]::validate_string
}
}
#
while {[llength $e] > 0} {
set e [::lassign $e field_spec]
switch $field_spec {
check_routine -
validator {
set e [::lassign $e validator]
}
maxlength -
bounds {
set e [::lassign $e bounds]
bounds_consistency $field_type bounds
}
default {
set e [::lassign $e default]
# we must not assume the variable 'default'
# exists in the dictionary because we
# set it only in this code branch
dict set form_definitions $form_name $field_name default $default
}
nonempty {
# this flag forces the formbroker to
# signal empty strings as form data errors
set nonemtpy 1
}
constrain {
set constrain 1
}
noconstrain {
set constrain 0
}
quote {
set force_quote 1
}
}
}
# let's check for possible inconsitencies between
# data type and default value. For this purpose
# we create a copy of the variable dictionary
# representation then we call the validator on it
set variable_d [dict get $form_definitions $form_name $field_name]
dict set variable_d var $default
if {[$validator variable_d] != "FB_OK"} {
dict unset form_definitions $form_name $field_name default
}
}
}
return [namespace current]::$form_name
}
# -- form_exists
#
# check the existence of the form named 'form_name' in the form broker
# database. The check is done by simply checking one of dictionaries that
# keep the internal database of form definitions
proc form_exists {form_command_name} {
variable form_definitions
return [dict exists $form_definitions [namespace tail $form_command_name]]
}
proc creategc {varname args} {
set formv [uplevel [list set $varname [::FormBroker::create {*}$args]]]
uplevel [list trace add variable $varname unset \
[list [namespace current]::destroy [namespace tail $formv]]]
return $formv
}
namespace export *
namespace ensemble create
}
package provide formbroker 1.0.1