Browse Source

Removed composer and added new classes

Giancarlo Voci 8 years ago
parent
commit
506537cbe4

+ 21 - 21
build.js

@@ -1,31 +1,31 @@
 var compressor = require('node-minify');
 
 compressor.minify({
-        compressor: 'clean-css',
-        input: [
-            "node_modules/bootstrap/dist/css/bootstrap.min.css",
-            "ui/custom.css"
-        ],
-        output: 'bundle.css'
-    }
+    compressor: 'clean-css',
+    input: [
+      "node_modules/bootstrap/dist/css/bootstrap.min.css",
+      "ui/custom.css"
+    ],
+    output: 'bundle.css'
+  }
 );
 
 compressor.minify({
-        compressor: 'uglifyjs',
-        input: [
-            "ui/custom.js"
-        ],
-        output: 'ui/custom.min.js'
-    }
+    compressor: 'uglifyjs',
+    input: [
+      "ui/custom.js"
+    ],
+    output: 'ui/custom.min.js'
+  }
 );
 
 compressor.minify({
-        compressor: 'no-compress',
-        input: [
-            "node_modules/jquery/dist/jquery.min.js",
-            "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js",
-            "ui/custom.min.js"
-        ],
-        output: 'bundle.js'
-    }
+    compressor: 'no-compress',
+    input: [
+      "node_modules/jquery/dist/jquery.min.js",
+      "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js",
+      "ui/custom.min.js"
+    ],
+    output: 'bundle.js'
+  }
 );

+ 0 - 16
composer.json

@@ -1,16 +0,0 @@
-{
-  "name": "openmind/lamborghini",
-  "type": "project",
-  "require": {
-    "bcosca/fatfree": "^3.6"
-  },
-  "authors": [
-    {
-      "name": "Open Mind Group",
-      "email": "service@openmind.srl"
-    }
-  ],
-  "autoload": {
-    "classmap": ["src/"]
-  }
-}

+ 0 - 49
composer.lock

@@ -1,49 +0,0 @@
-{
-    "_readme": [
-        "This file locks the dependencies of your project to a known state",
-        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
-        "This file is @generated automatically"
-    ],
-    "content-hash": "48a851153b2aa11924776d02aee63d52",
-    "packages": [
-        {
-            "name": "bcosca/fatfree",
-            "version": "3.6.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/bcosca/fatfree.git",
-                "reference": "dee4d73918a42076d5ac7db64d6d39706c7f328a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/bcosca/fatfree/zipball/dee4d73918a42076d5ac7db64d6d39706c7f328a",
-                "reference": "dee4d73918a42076d5ac7db64d6d39706c7f328a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.4"
-            },
-            "type": "library",
-            "autoload": {
-                "files": [
-                    "lib/base.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "GPL-3.0"
-            ],
-            "description": "A powerful yet easy-to-use PHP micro-framework designed to help you build dynamic and robust Web applications - fast!",
-            "homepage": "http://fatfreeframework.com/",
-            "time": "2018-04-19T17:23:25+00:00"
-        }
-    ],
-    "packages-dev": [],
-    "aliases": [],
-    "minimum-stability": "stable",
-    "stability-flags": [],
-    "prefer-stable": false,
-    "prefer-lowest": false,
-    "platform": [],
-    "platform-dev": []
-}

+ 12 - 12
config.ini

@@ -1,21 +1,21 @@
 [globals]
-DBSETTINGS="mysql:host=localhost;port=3306;dbname=omlink_lamborghini"
-dbuser=root
-dbpass=giancarlo
 AUTOLOAD="src/"
-BASEURL = /lamborghini
 ;CACHE = "folder=cache/"
 CACHE = false
-CACHE_TTL = 1
-SITENAME = OM-Link Lamborghini
-UI = ui/
-DEBUG = 3
-ENCODING = "UTF-8"
-HIGHLIGHT = false
-JAR.expire = {{time()+31536000}}
+CACHE_TTL=1
+UI=ui/
+DEBUG=3
+ENCODING="UTF-8"
+HIGHLIGHT=false
+JAR.expire={{time()+31536000}}
+DBSETTINGS="mysql:host=localhost;port=3306;dbname=omlink_lamborghini"
+dbuser=root
+dbpass=giancarlo
+BASEURL=/lamborghini
+SITENAME=OM-Link Lamborghini
 
 [CORE.fb]
-host=192.168.1.141
+host=192.168.1.140
 port=50443
 key=C0000000000011511187894
 pass=X8sCUiGBEbWTRWCUW72n95tRT7wYriac

+ 2 - 1
index.php

@@ -7,9 +7,10 @@
 
 //error_reporting(E_ALL);
 
-require 'vendor/autoload.php';
+require_once 'lib/f3/base.php';
 
 $f3 = \Base::instance();
+$f3->set("AUTOLOAD", 'src/');
 
 \OMUtils::Instance()->LoadEncryptedConfig('config.bin');
 

+ 872 - 0
lib/f3/CHANGELOG.md

@@ -0,0 +1,872 @@
+CHANGELOG
+
+3.6.4 (19 April 2018)
+*	NEW: Added Dependency Injection support with CONTAINER variable [#221](https://github.com/bcosca/fatfree-core/issues/221)
+*	NEW: configurable LOGGABLE error codes [#1091](https://github.com/bcosca/fatfree/issues/1091#issuecomment-364674701)
+*	NEW: JAR.lifetime option, [#178](https://github.com/bcosca/fatfree-core/issues/178)
+*	Template: reduced Prefab calls
+*	Template: optimized reflection for better derivative support, [bcosca/fatfree#1088](https://github.com/bcosca/fatfree/issues/1088)
+*	Template: optimized parsing for template attributes and tokens
+*	DB\Mongo: fixed logging with mongodb extention
+*	DB\Jig: added lazy-loading [#7e1cd9b9b89](https://github.com/bcosca/fatfree-core/commit/7e1cd9b9b89c4175d0f6b86ced9d9bd49c04ac39)
+*	DB\Jig\Mapper: Added group feature, bcosca/fatfree#616
+*	DB\SQL\Mapper: fix PostgreSQL RETURNING ID when no pkey is available, [bcosca/fatfree#1069](https://github.com/bcosca/fatfree/issues/1069), [#230](https://github.com/bcosca/fatfree-core/issues/230)
+*	DB\SQL\Mapper: disable order clause auto-quoting when it's already been quoted
+*	Web->location: add failsafe for geoip_region_name_by_code() [#GB:Bxyn9xn9AgAJ](https://groups.google.com/d/msg/f3-framework/APau4wnwNzE/Bxyn9xn9AgAJ)
+*	Web->request: Added proxy support [#e936361b](https://github.com/bcosca/fatfree-core/commit/e936361bc03010c4c7c38a396562e5e96a8a100d)
+*	Web->mime: Added JFIF format
+*	Markdown: handle line breaks in paragraph blocks, [bcosca/fatfree#1100](https://github.com/bcosca/fatfree/issues/1100)
+*	config: reduced cast calls on parsing config sections
+*	Patch empty SERVER_NAME [bcosca/fatfree#1084](https://github.com/bcosca/fatfree/issues/1084)
+*	Bugfix: unreliable request headers in Web->request() response [bcosca/fatfree#1092](https://github.com/bcosca/fatfree/issues/1092)
+*	Fixed, View->render: utilizing multiple UI paths, [bcosca/fatfree#1083](https://github.com/bcosca/fatfree/issues/1083)
+*	Fixed URL parsing with PHP 5.4 [#247](https://github.com/bcosca/fatfree-core/issues/247)
+*	Fixed PHP 7.2 warnings when session is active prematurely, [#238](https://github.com/bcosca/fatfree-core/issues/238)
+*	Fixed setcookie $expire variable type [#240](https://github.com/bcosca/fatfree-core/issues/240)
+*	Fixed expiration time when updating an existing cookie
+
+3.6.3 (31 December 2017)
+*	PHP7 fix: remove deprecated (unset) cast
+*	Web->request: restricted follow_location to 3XX responses only
+*	CLI mode: refactored arguments parsing
+*	CLI mode: fixed query string encoding
+*	SMTP: Refactor parsing of attachments
+*	SMTP: clean-up mail headers for multipart messages, [#1065](https://github.com/bcosca/fatfree/issues/1065)
+*	config: fixed performance issues on parsing config files
+*	config: cast command parameters in config entries to php type & constant, [#1030](https://github.com/bcosca/fatfree/issues/1030)
+*	config: reduced registry calls
+*	config: skip hive escaping when resolving dynamic config vars, [#1030](https://github.com/bcosca/fatfree/issues/1030)
+*	Bug fix: Incorrect cookie lifetime computation, [#1070](https://github.com/bcosca/fatfree/issues/1070), [#1016](https://github.com/bcosca/fatfree/issues/1016)
+*	DB\SQL\Mapper: use RETURNING option instead of a sequence query to get lastInsertId in PostgreSQL, [#1069](https://github.com/bcosca/fatfree/issues/1069), [#230](https://github.com/bcosca/fatfree-core/issues/230)
+*	DB\SQL\Session: check if _agent is too long for SQL based sessions [#236](https://github.com/bcosca/fatfree-core/issues/236)
+*	DB\SQL\Session: fix Session handler table creation issue on SQL Server, [#899](https://github.com/bcosca/fatfree/issues/899)
+*	DB\SQL: fix oracle db issue with empty error variable, [#1072](https://github.com/bcosca/fatfree/issues/1072)
+*	DB\SQL\Mapper: fix sorting issues on SQL Server, [#1052](https://github.com/bcosca/fatfree/issues/1052) [#225](https://github.com/bcosca/fatfree-core/issues/225)
+*	Prevent directory traversal attacks on filesystem based cache [#1073](https://github.com/bcosca/fatfree/issues/1073)
+*	Bug fix, Template: PHP constants used in include with attribute, [#983](https://github.com/bcosca/fatfree/issues/983)
+*	Bug fix, Template: Numeric value in expression alters PHP_EOL context
+*	Template: use existing linefeed instead of PHP_EOL, [#1048](https://github.com/bcosca/fatfree/issues/1048)
+*	Template: make newline interpolation handling configurable [#223](https://github.com/bcosca/fatfree-core/issues/223)
+*	Template: add beforerender to Preview
+*	fix custom FORMATS without modifiers
+*	Cache: Refactor Cache->reset for XCache
+*	Cache: loosen reset cache key pattern, [#1041](https://github.com/bcosca/fatfree/issues/1041)
+*	XCache: suffix reset only works if xcache.admin.enable_auth is disabled
+*	Added HTTP 103 as recently approved by the IETF
+*	LDAP changes to for AD flexibility [#227](https://github.com/bcosca/fatfree-core/issues/227)
+*	Hide debug trace from ajax errors when DEBUG=0 [#1071](https://github.com/bcosca/fatfree/issues/1071)
+*	fix View->render using potentially wrong cache entry
+
+3.6.2 (26 June 2017)
+*   Return a status code > 0 when dying on error [#220](https://github.com/bcosca/fatfree-core/issues/220)
+*   fix SMTP line width [#215](https://github.com/bcosca/fatfree-core/issues/215)
+*   Allow using a custom field for ldap user id checking [#217](https://github.com/bcosca/fatfree-core/issues/217)
+*   NEW: DB\SQL->exists: generic method to check if SQL table exists
+*   Pass handler to route handler and hooks [#1035](https://github.com/bcosca/fatfree/issues/1035)
+*   pass carriage return of multiline dictionary keys
+*   Better Web->slug customization
+*   fix incorrect header issue [#211](https://github.com/bcosca/fatfree-core/issues/211)
+*   fix schema issue on databases with case-sensitive collation, fixes [#209](https://github.com/bcosca/fatfree-core/issues/209)
+*   Add filter for deriving C-locale equivalent of a number
+*   Bug fix: @LANGUAGE remains unchanged after override
+*   abort: added Header pre-check
+*   Assemble URL after ONREROUTE
+*   Add reroute argument to skip script termination
+*   Invoke ONREROUTE after headers are sent
+*   SQLite switch to backtick as quote
+*   Bug fix: Incorrect timing in SQL query logs
+*   DB\SQL\Mapper: Cast return value of count to integer
+*   Patched $_SERVER['REQUEST_URI'] to ensure it contains a relative URI
+*   Tweak debug verbosity
+*   fix php carriage return issue in preview->build [#205](https://github.com/bcosca/fatfree-core/pull/205)
+*   fixed template string resolution [#205](https://github.com/bcosca/fatfree-core/pull/205)
+*   Fixed unexpected default seed on CACHE set [#1028](https://github.com/bcosca/fatfree/issues/1028)
+*   DB\SQL\Mapper: Optimized field escaping on options
+*   Optimize template conversion to PHP file
+
+3.6.1 (2 April 2017)
+*	NEW: Recaptcha plugin [#194](https://github.com/bcosca/fatfree-core/pull/194)
+*	NEW: MB variable for detecting multibyte support
+*	NEW: DB\SQL: Cache parsed schema for the TTL duration
+*	NEW: quick erase flag on Jig/Mongo/SQL mappers [#193](https://github.com/bcosca/fatfree-core/pull/193)
+*	NEW: Allow OPTIONS method to return a response body [#171](https://github.com/bcosca/fatfree-core/pull/171)
+*	NEW: Add support for Memcached (bcosca/fatfree#997)
+*	NEW: Rudimentary preload resource (HTTP2 server) support via template push()
+*	NEW: Add support for new MongoDB driver [#177](https://github.com/bcosca/fatfree-core/pull/177)
+*	Changed: template filter are all lowercase now
+*	Changed: Fix template lookup inconsistency: removed base dir from UI on render
+*	Changed: count() method now has an options argument [#192](https://github.com/bcosca/fatfree-core/pull/192)
+*	Changed: SMTP, Spit out error message if any
+*	\DB\SQL\Mapper: refactored row count strategy
+*	DB\SQL\Mapper: Allow non-scalar values to be assigned as mapper property
+*	DB\SQL::PARAM_FLOAT: remove cast to float (#106 and bcosca/fatfree#984) (#191)
+*	DB\SQL\mapper->erase: allow empty string
+*	DB\SQL\mapper->insert: fields reset after successful INSERT
+*	Add option to debounce Cursor->paginate subset [#195](https://github.com/bcosca/fatfree-core/pull/195)
+*	View: Don't delete sandboxed variables (#198)
+*	Preview: Optimize compilation of template expressions
+*	Preview: Use shorthand tag for direct rendering
+*	Preview->resolve(): new tweak to allow template persistence as option
+*	Web: Expose diacritics translation table
+*	SMTP: Enable logging of message body only when $log argument is 'verbose'
+*	SMTP: Convert headers to camelcase for consistency
+*	make cache seed more flexible, #164
+*	Improve trace details for DEBUG>2
+*	Enable config() to read from an array of input files
+*	Improved alias and reroute regex
+*	Make camelCase and snakeCase Unicode-aware
+*	format: Provision for optional whitespaces
+*	Break APCu-BC dependence
+*	Old PHP 5.3 cleanup
+*	Debug log must include HTTP query
+*	Recognize X-Forwarded-Port header (bcosca/fatfree#1002)
+*	Avoid use of deprecated mcrypt module
+*	Return only the client's IP when using the `X-Forwarded-For` header to deduce an IP address
+*	Remove orphan mutex locks on termination (#157)
+*	Use 80 as default port number to avoid issues when `$_SERVER['SERVER_PORT']` is not existing
+*	fread replaced with readfile() for simple send() usecase
+*	Bug fix: request URI with multiple leading slashes, #203
+*	Bug fix: Query generates wrong adhoc field value
+*	Bug fix: SMTP stream context issue #200
+*	Bug fix: child pseudo class selector in minify, bcosca/fatfree#1008
+*	Bug fix: "Undefined index: CLI" error (#197)
+*	Bug fix: cast Cache-Control expire time to int, bcosca/fatfree#1004
+*	Bug fix: Avoid issuance of multiple Content-Type headers for nested templates
+*	Bug fix: wildcard token issue with digits (bcosca/fatfree#996)
+*	Bug fix: afterupdate ignored when row does not change
+*	Bug fix: session handler read() method for PHP7 (need strict string) #184 #185
+*	Bug fix: reroute mocking in CLI mode (#183)
+*	Bug fix: Reroute authoritative relative references (#181)
+*	Bug fix: locales order and charset hyphen
+*	Bug fix: base stripped twice in router (#176)
+
+3.6.0 (19 November 2016)
+*	NEW: [cli] request type
+*	NEW: console-friendly CLI mode
+*	NEW: lexicon caching
+*	NEW: Silent operator skips startup error check (#125)
+*	NEW: DB\SQL->trans()
+*	NEW: custom config section parser, i.e. [conf > Foo::bar]
+*	NEW: support for cache tags in SQL
+*	NEW: custom FORMATS
+*	NEW: Mongo mapper fields whitelist
+*	NEW: WebSocket server
+*	NEW: Base->extend method (#158)
+*	NEW: Implement framework variable caching via config, i.e. FOO = "bar" | 3600
+*	NEW: Lightweight OAuth2 client
+*	NEW: SEED variable, configurable app-specific hashing prefix (#149, bcosca/fatfree#951, bcosca/fatfree#884, bcosca/fatfree#629)
+*	NEW: CLI variable
+*	NEW: Web->send, specify custom filename (#124)
+*	NEW: Web->send, added flushing flag (#131)
+*	NEW: Indexed route wildcards, now exposed in PARAMS['*']
+*	Changed: PHP 5.4 is now the minimum version requirement
+*	Changed: Prevent database wrappers from being cloned
+*	Changed: Router works on PATH instead of URI (#126) NB: PARAMS.0 no longer contains the query string
+*	Changed: Removed ALIASES autobuilding (#118)
+*	Changed: Route wildcards match empty strings (#119)
+*	Changed: Disable default debug highlighting, HIGHLIGHT is false now
+*	General PHP 5.4 optimizations
+*	Optimized config parsing
+*	Optimized Base->recursive
+*	Optimized header extraction
+*	Optimized cache/expire headers
+*	Optimized session_start behaviour (bcosca/fatfree#673)
+*	Optimized reroute regex
+*	Tweaked cookie removal
+*	Better route precedence order
+*	Performance tweak: reduced cache calls
+*	Refactored lexicon (LOCALES) build-up, much faster now
+*	Added turkish locale bug workaround
+*	Geo->tzinfo Update to UTC
+*	Added Xcache reset (bcosca/fatfree#928)
+*	Redis cache: allow db name in dsn
+*	SMTP: Improve server emulation responses
+*	SMTP: Optimize transmission envelope
+*	SMTP: Implement mock transmission
+*	SMTP: Various bug fixes and feature improvements
+*	SMTP: quit on failed authentication
+*	Geo->weather: force metric units
+*	Base->until: Implement CLI interoperability
+*	Base->format: looser plural syntax
+*	Base->format: Force decimal as default number format
+*	Base->merge: Added $keep flag to save result to the hive key
+*	Base->reroute: Allow array as URL argument for aliasing
+*	Base->alias: Allow query string (or array) to be appended to alias
+*	Permit reroute to named routes with URL query segment
+*	Sync COOKIE global on set()
+*	Permit non-hive variables to use JS dot notation
+*	RFC2616: Use absolute URIs for Location header
+*	Matrix->calendar: Check if calendar extension is loaded
+*	Markdown: require start of line/whitespace for text processing (#136)
+*	DB\[SQL|Jig|Mongo]->log(FALSE) disables logging
+*	DB\SQL->exec: Added timestamp toggle to db log
+*	DB\SQL->schema: Remove unnecessary line terminators
+*	DB\SQL\Mapper: allow array filter with empty string
+*	DB\SQL\Mapper: optimized handling for key-less tables
+*	DB\SQL\Mapper: added float support (#106)
+*	DB\SQL\Session: increased default column sizes (#148, bcosca/fatfree#931, bcosca/fatfree#950)
+*	Web: Catch cURL errors
+*	Optimize Web->receive (bcosca/fatfree#930)
+*	Web->minify: fix arbitrary file download vulnerability
+*	Web->request: fix cache control max-age detection (bcosca/fatfree#908)
+*	Web->request: Add request headers & error message to return value (bcosca/fatfree#737)
+*	Web->request: Refactored response to HTTP request
+*	Web->send flush while sending big files
+*	Image->rgb: allow hex strings
+*	Image->captcha: Check if GD module supports TrueType
+*	Image->load: Return FALSE on load failure
+*	Image->resize: keep aspect ratio when only width or height was given
+*	Updated OpenID lib (bcosca/fatfree#965)
+*	Audit->card: add new mastercard "2" BIN range (bcosca/fatfree#954)
+*	Deprecated: Bcrypt class
+*	Preview->render: optimized detection to remove short open PHP tags and allow xml tags (#133)
+*	Display file and line number in exception handler (bcosca/fatfree#967)
+*	Added error reporting level to Base->error and ERROR.level (bcosca/fatfree#957)
+*	Added optional custom cache instance to Session (#141)
+*	CLI-aware mock()
+*	XFRAME and PACKAGE can be switched off now (#128)
+*	Bug fix: wrong time calculation on memcache reset (#170)
+*	Bug fix: encode CLI parameters
+*	Bug fix: Close connection on abort explicitly (#162)
+*	Bug fix: Image->identicon, Avoid double-size sprite rotation (and possible segfault)
+*	Bug fix: Image->render and Image->dump, removed unnecessary 2nd argument (#146)
+*	Bug fix: Magic->offsetset, access property as array element (#147)
+*	Bug fix: multi-line custom template tag parsing (bcosca/fatfree#935)
+*	Bug fix: cache headers on errors (bcosca/fatfree#885)
+*	Bug fix: Web, deprecated CURLOPT_SSL_VERIFYHOST in curl
+*	Bug fix: Web, Invalid user error constant (bcosca/fatfree#962)
+*	Bug fix: Web->request, redirections for domain-less location (#135)
+*	Bug fix: DB\SQL\Mapper, reset changed flag after update (#142, #152)
+*	Bug fix: DB\SQL\Mapper, fix changed flag when using assignment operator #143 #150 #151
+*	Bug fix: DB\SQL\Mapper, revival of the HAVING clause
+*	Bug fix: DB\SQL\Mapper, pgsql with non-integer primary keys (bcosca/fatfree#916)
+*	Bug fix: DB\SQL\Session, quote table name (bcosca/fatfree#977)
+*	Bug fix: snakeCase returns word starting with underscore (bcosca/fatfree#927)
+*	Bug fix: mock does not populate PATH variable
+*	Bug fix: Geo->weather API key (#129)
+*	Bug fix: Incorrect compilation of array element with zero index
+*	Bug fix: Compilation of array construct is incorrect
+*	Bug fix: Trailing slash redirection on UTF-8 paths (#121)
+
+3.5.1 (31 December 2015)
+*	NEW: ttl attribute in <include> template tag
+*	NEW: allow anonymous function for template filter
+*	NEW: format modifier for international and custom currency symbol
+*	NEW: Image->data() returns image resource
+*	NEW: extract() get prefixed array keys from an assoc array
+*	NEW: Optimized and faster Template parser with full support for HTML5 empty tags
+*	NEW: Added support for {@token} encapsulation syntax in routes definition
+*	NEW: DB\SQL->exec(), automatically shift to 1-based query arguments
+*	NEW: abort() flush output
+*	Added referenced value to devoid()
+*	Template token filters are now resolved within Preview->token()
+*	Web->_curl: restrict redirections to HTTP
+*	Web->minify(), skip importing of external files
+*	Improved session and error handling in until()
+*	Get the error trace array with the new $format parameter
+*	Better support for unicode URLs
+*	Optimized TZ detection with date_default_timezone_get()
+*	format() Provide default decimal places
+*	Optimize code: remove redundant TTL checks
+*	Optimized timeout handling in Web->request()
+*	Improved PHPDoc hints
+*	Added missing russian DIACRITICS letters
+*	DB\Cursor: allow child implementation of reset()
+*	DB\Cursor: Copyfrom now does an internal call to set()
+*	DB\SQL: Provide the ability to disable SQL logging
+*	DB\SQL: improved query analysis to trigger fetchAll
+*	DB\SQL\Mapper: added support for binary table columns
+*	SQL,JIG,MONGO,CACHE Session handlers refactored and optimized
+*	SMTP Refactoring and optimization
+*	Bug fix: SMTP, Align quoted_printable_encode() with SMTP specs (dot-stuffing)
+*	Bug fix: SMTP, Send buffered optional headers to output
+*	Bug fix: SMTP, Content-Transfer-Encoding for non-TLS connections
+*	Bug fix: SMTP, Single attachment error
+*	Bug fix: Cursor->load not always mapping to first record
+*	Bug fix: dry SQL mapper should not trigger 'load'
+*	Bug fix: Code highlighting on empty text
+*	Bug fix: Image->resize, round dimensions instead of cast
+*	Bug fix: whitespace handling in $f3->compile()
+*	Bug fix: TTL of `View` and `Preview` (`Template`)
+*	Bug fix: token filter regex
+*	Bug fix: Template, empty attributes
+*	Bug fix: Preview->build() greedy regex
+*	Bug fix: Web->minify() single-line comment on last line
+*	Bug fix: Web->request(), follow_location with cURL and open_basedir
+*	Bug fix: Web->send() Single quotes around filename not interpreted correctly by some browsers
+
+3.5.0 (2 June 2015)
+*	NEW: until() method for long polling
+*	NEW: abort() to disconnect HTTP client (and continue execution)
+*	NEW: SQL Mapper->required() returns TRUE if field is not nullable
+*	NEW: PREMAP variable for allowing prefixes to handlers named after HTTP verbs
+*	NEW: [configs] section to allow config includes
+*	NEW: Test->passed() returns TRUE if no test failed
+*	NEW: SQL mapper changed() function
+*	NEW: fatfree-core composer support
+*	NEW: constants() method to expose constants
+*	NEW: Preview->filter() for configurable token filters
+*	NEW: CORS variable for Cross-Origin Resource Sharing support, #731
+*	Change in behavior: Switch to htmlspecialchars for escaping
+*	Change in behavior: No movement in cursor position after erase(), #797
+*	Change in behavior: ERROR.trace is a multiline string now
+*	Change in behavior: Strict token recognition in <include> href attribute
+*	Router fix: loose method search
+*	Better route precedence order, #12
+*	Preserve contents of ROUTES, #723
+*	Alias: allow array of parameters
+*	Improvements on reroute method
+*	Fix for custom Jig session files
+*	Audit: better mobile detection
+*	Audit: add argument to test string as browser agent
+*	DB mappers: abort insert/update/erase from hooks, #684
+*	DB mappers: Allow array inputs in copyfrom()
+*	Cache,SQL,Jig,Mongo Session: custom callback for suspect sessions
+*	Fix for unexpected HIVE values when defining an empty HIVE array
+*	SQL mapper: check for results from CALL and EXEC queries, #771
+*	SQL mapper: consider SQL schema prefix, #820
+*	SQL mapper: write to log before execution to
+	enable tracking of PDOStatement error
+*	Add SQL Mapper->table() to return table name
+*	Allow override of the schema in SQL Mapper->schema()
+*	Improvement: Keep JIG table as reference, #758
+*	Expand regex to include whitespaces in SQL DB dsn, #817
+*	View: Removed reserved variables $fw and $implicit
+*	Add missing newlines after template expansion
+*	Web->receive: fix for complex field names, #806
+*	Web: Improvements in socket engine
+*	Web: customizable user_agent for all engines, #822
+*	SMTP: Provision for Content-ID in attachments
+*	Image + minify: allow absolute paths
+*	Promote framework error to E_USER_ERROR
+*	Geo->weather switch to OpenWeather
+*	Expose mask() and grab() methods for routing
+*	Expose trace() method to expose the debug backtrace
+*	Implement recursion strategy using IteratorAggregate, #714
+*	Exempt whitespace between % and succeeding operator from being minified, #773
+*	Optimized error detection and ONERROR handler, fatfree-core#18
+*	Tweak error log output
+*	Optimized If-Modified-Since cache header usage
+*	Improved APCu compatibility, #724
+*	Bug fix: Web::send fails on filename with spaces, #810
+*	Bug fix: overwrite limit in findone()
+*	Bug fix: locale-specific edge cases affecting SQL schema, #772
+*	Bug fix: Newline stripping in config()
+*	Bug fix: bracket delimited identifier for sybase and dblib driver
+*	Bug fix: Mongo mapper collection->count driver compatibility
+*	Bug fix: SQL Mapper->set() forces adhoc value if already defined
+*	Bug fix: Mapper ignores HAVING clause
+*	Bug fix: Constructor invocation in call()
+*	Bug fix: Wrong element returned by ajax/sync request
+*	Bug fix: handling of non-consecutive compound key members
+*	Bug fix: Virtual fields not retrieved when group option is present, #757
+*	Bug fix: group option generates incorrect SQL query, #757
+*	Bug fix: ONERROR does not receive PARAMS on fatal error
+
+3.4.0 (1 January 2015)
+*	NEW: [redirects] section
+*	NEW: Custom config sections
+*	NEW: User-defined AUTOLOAD function
+*	NEW: ONREROUTE variable
+*	NEW: Provision for in-memory Jig database (#727)
+*	Return run() result (#687)
+*	Pass result of run() to mock() (#687)
+*	Add port suffix to REALM variable
+*	New attribute in <include> tag to extend hive
+*	Adjust unit tests and clean up templates
+*	Expose header-related methods
+*	Web->request: allow content array
+*	Preserve contents of ROUTES (#723)
+*	Smart detection of PHP functions in template expressions
+*	Add afterrender() hook to View class
+*	Implement ArrayAccess and magic properties on hive
+*	Improvement on mocking of superglobals and request body
+*	Fix table creation for pgsql handled sessions
+*	Add QUERY to hive
+*	Exempt E_NOTICE from default error_reporting()
+*	Add method to build alias routes from template, fixes #693
+*	Fix dangerous caching of cookie values
+*	Fix multiple encoding in nested templates
+*	Fix node attribute parsing for empty/zero values
+*	Apply URL encoding on BASE to emulate v2 behavior (#123)
+*	Improve Base->map performance (#595)
+*	Add simple backtrace for fatal errors
+*	Count Cursor->load() results (#581)
+*	Add form field name to Web->receive() callback arguments
+*	Fix missing newlines after template expansion
+*	Fix overwrite of ENCODING variable
+*	limit & offset workaround for SQL Server, fixes #671
+*	SQL Mapper->find: GROUP BY SQL compliant statement
+*	Bug fix: Missing abstract method fields()
+*	Bug fix: Auto escaping does not work with mapper objects (#710)
+*	Bug fix: 'with' attribute in <include> tag raise error when no token
+	inside
+*	View rendering: optional Content-Type header
+*	Bug fix: Undefined variable: cache (#705)
+*	Bug fix: Routing does not work if project base path includes valid
+	special URI character (#704)
+*	Bug fix: Template hash collision (#702)
+*	Bug fix: Property visibility is incorrect (#697)
+*	Bug fix: Missing Allow header on HTTP 405 response
+*	Bug fix: Double quotes in lexicon files (#681)
+*	Bug fix: Space should not be mandatory in ICU pluralization format string
+*	Bug fix: Incorrect log entry when SQL query contains a question mark
+*	Bug fix: Error stack trace
+*	Bug fix: Cookie expiration (#665)
+*	Bug fix: OR operator (||) parsed incorrectly
+*	Bug fix: Routing treatment of * wildcard character
+*	Bug fix:  Mapper copyfrom() method doesn't allow class/object callbacks
+	(#590)
+*	Bug fix: exists() creates elements/properties (#591)
+*	Bug fix: Wildcard in routing pattern consumes entire query string (#592)
+*	Bug fix: Workaround bug in latest MongoDB driver
+*	Bug fix: Default error handler silently fails for AJAX request with
+	DEBUG>0 (#599)
+*	Bug fix: Mocked BODY overwritten (#601)
+*	Bug fix: Undefined pkey (#607)
+
+3.3.0 (8 August 2014)
+*	NEW: Attribute in <include> tag to extend hive
+*	NEW: Image overlay with transparency and alignment control
+*	NEW: Allow redirection of specified route patterns to a URL
+*	Bug fix: Missing AND operator in SQL Server schema query (Issue #576)
+*	Count Cursor->load() results (Feature request #581)
+*	Mapper copyfrom() method doesn't allow class/object callbacks (Issue #590)
+*	Bug fix: exists() creates elements/properties (Issue #591)
+*	Bug fix: Wildcard in routing pattern consumes entire query string
+	(Issue #592)
+*	Tweak Base->map performance (Issue #595)
+*	Bug fix: Default error handler silently fails for AJAX request with
+	DEBUG>0 (Issue #599)
+*	Bug fix: Mocked BODY overwritten (Issue #601)
+*	Bug fix: Undefined pkey (Issue #607)
+*	Bug fix: beforeupdate() position (Issue #633)
+*	Bug fix: exists() return value for cached keys
+*	Bug fix: Missing error code in UNLOAD handler
+*	Bug fix: OR operator (||) parsed incorrectly
+*	Add input name parameter to custom slug function
+*	Apply URL encoding on BASE to emulate v2 behavior (Issue #123)
+*	Reduce mapper update() iterations
+*	Bug fix: Routing treatment of * wildcard character
+*	SQL Mapper->find: GROUP BY SQL compliant statement
+*	Work around bug in latest MongoDB driver
+*	Work around probable race condition and optimize cache access
+*	View rendering: Optional Content-Type header
+*	Fix missing newlines after template expansion
+*	Add form field name to Web->receive() callback arguments
+*	Quick reference: add RAW variable
+
+3.2.2 (19 March 2014)
+*	NEW: Locales set automatically (Feature request #522)
+*	NEW: Mapper dbtype()
+*	NEW: before- and after- triggers for all mappers
+*	NEW: Decode HTML5 entities if PHP>5.3 detected (Feature request #552)
+*	NEW: Send credentials only if AUTH is present in the SMTP extension
+	response (Feature request #545)
+*	NEW: BITMASK variable to allow ENT_COMPAT override
+*	NEW: Redis support for caching
+*	Enable SMTP feature detection
+*	Enable extended ICU custom date format (Feature request #555)
+*	Enable custom time ICU format
+*	Add option to turn off session table creation (Feature request #557)
+*	Enhanced template token rendering and custom filters (Feature request
+	#550)
+*	Avert multiple loads in DB-managed sessions (Feature request #558)
+*	Add EXEC to associative fetch
+*	Bug fix: Building template tokens breaks on inline OR condition (Issue
+	#573)
+*	Bug fix: SMTP->send does not use the $log parameter (Issue #571)
+*	Bug fix: Allow setting sqlsrv primary keys on insert (Issue #570)
+*	Bug fix: Generated query for obtaining table schema in sqlsrv incorrect
+	(Bug #565)
+*	Bug fix: SQL mapper flag set even when value has not changed (Bug #562)
+*	Bug fix: Add XFRAME config option (Feature request #546)
+*	Bug fix: Incorrect parsing of comments (Issue #541)
+*	Bug fix: Multiple Set-Cookie headers (Issue #533)
+*	Bug fix: Mapper is dry after save()
+*	Bug fix: Prevent infinite loop when error handler is triggered
+	(Issue #361)
+*	Bug fix: Mapper tweaks not passing primary keys as arguments
+*	Bug fix: Zero indexes in dot-notated arrays fail to compile
+*	Bug fix: Prevent GROUP clause double-escaping
+*	Bug fix: Regression of zlib compression bug
+*	Bug fix: Method copyto() does not include ad hoc fields
+*	Check existence of OpenID mode (Issue #529)
+*	Generate a 404 when a tokenized class doesn't exist
+*	Fix SQLite quotes (Issue #521)
+*	Bug fix: BASE is incorrect on Windows
+
+3.2.1 (7 January 2014)
+*	NEW: EMOJI variable, UTF->translate(), UTF->emojify(), and UTF->strrev()
+*	Allow empty strings in config()
+*	Add support for turning off php://input buffering via RAW
+	(FALSE by default)
+*	Add Cursor->load() and Cursor->find() TTL support
+*	Support Web->receive() large file downloads via PUT
+*	ONERROR safety check
+*	Fix session CSRF cookie detection
+*	Framework object now passed to route handler contructors
+*	Allow override of DIACRITICS
+*	Various code optimizations
+*	Support log disabling (Issue #483)
+*	Implicit mapper load() on authentication
+*	Declare abstract methods for Cursor derivatives
+*	Support single-quoted HTML/XML attributes (Feature request #503)
+*	Relax property visibility of mappers and derivatives
+*	Deprecated: {{~ ~}} instructions and {{* *}} comments; Use {~ ~} and
+	{* *} instead
+*	Minor fix: Audit->ipv4() return value
+*	Bug fix: Backslashes in BASE not converted on Windows
+*	Bug fix: UTF->substr() with negative offset and specified length
+*	Bug fix: Replace named URL tokens on render()
+*	Bug fix: BASE is not empty when run from document root
+*	Bug fix: stringify() recursion
+
+3.2.0 (18 December 2013)
+*	NEW: Automatic CSRF protection (with IP and User-Agent checks) for
+	sessions mapped to SQL-, Jig-, Mongo- and Cache-based backends
+*	NEW: Named routes
+*	NEW: PATH variable; returns the URL relative to BASE
+*	NEW: Image->captcha() color parameters
+*	NEW: Ability to access MongoCuror thru the cursor() method
+*	NEW: Mapper->fields() method returns array of field names
+*	NEW: Mapper onload(), oninsert(), onupdate(), and onerase() event
+	listeners/triggers
+*	NEW: Preview class (a lightweight template engine)
+*	NEW: rel() method derives path from URL relative to BASE; useful for
+	rerouting
+*	NEW: PREFIX variable for prepending a string to a dictionary term;
+	Enable support for prefixed dictionary arrays and .ini files (Feature
+	request #440)
+*	NEW: Google static map plugin
+*	NEW: devoid() method
+*	Introduce clean(); similar to scrub(), except that arg is passed by
+	value
+*	Use $ttl for cookie expiration (Issue #457)
+*	Fix needs_rehash() cost comparison
+*	Add pass-by-reference argument to exists() so if method returns TRUE,
+	a subsequent get() is unnecessary
+*	Improve MySQL support
+*	Move esc(), raw(), and dupe() to View class where they more
+	appropriately belong
+*	Allow user-defined fields in SQL mapper constructor (Feature request
+	#450)
+*	Re-implement the pre-3.0 template resolve() feature
+*	Remove redundant instances of session_commit()
+*	Add support for input filtering in Mapper->copyfrom()
+*	Prevent intrusive behavior of Mapper->copyfrom()
+*	Support multiple SQL primary keys
+*	Support custom tag attributes/inline tokens defined at runtime
+	(Feature request #438)
+*	Broader support for HTTP basic auth
+*	Prohibit Jig _id clear()
+*	Add support for detailed stringify() output
+*	Add base directory to UI path as fallback
+*	Support Test->expect() chaining
+*	Support __tostring() in stringify()
+*	Trigger error on invalid CAPTCHA length (Issue #458)
+*	Bug fix: exists() pass-by-reference argument returns incorrect value
+*	Bug fix: DB Exec does not return affected row if query contains a
+	sub-SELECT (Issue #437)
+*	Improve seed generator and add code for detecting of acceptable
+	limits in Image->captcha() (Feature request #460)
+*	Add decimal format ICU extension
+*	Bug fix: 404-reported URI contains HTTP query
+*	Bug fix: Data type detection in DB->schema()
+*	Bug fix: TZ initialization
+*	Bug fix: paginate() passes incorrect argument to count()
+*	Bug fix: Incorrect query when reloading after insert()
+*	Bug fix: SQL preg_match error in pdo_type matching (Issue #447)
+*	Bug fix: Missing merge() function (Issue #444)
+*	Bug fix: BASE misdefined in command line mode
+*	Bug fix: Stringifying hive may run infinite (Issue #436)
+*	Bug fix: Incomplete stringify() when DEBUG<3 (Issue #432)
+*	Bug fix: Redirection of basic auth (Issue #430)
+*	Bug fix: Filter only PHP code (including short tags) in templates
+*	Bug fix: Markdown paragraph parser does not convert PHP code blocks
+	properly
+*	Bug fix: identicon() colors on same keys are randomized
+*	Bug fix: quotekey() fails on aliased keys
+*	Bug fix: Missing _id in Jig->find() return value
+*	Bug fix: LANGUAGE/LOCALES handling
+*	Bug fix: Loose comparison in stringify()
+
+3.1.2 (5 November 2013)
+*	Abandon .chm help format; Package API documentation in plain HTML;
+	(Launch lib/api/index.html in your browser)
+*	Deprecate BAIL in favor of HALT (default: TRUE)
+*	Revert to 3.1.0 autoload behavior; Add support for lowercase folder
+	names
+*	Allow Spring-style HTTP method overrides
+*	Add support for SQL Server-based sessions
+*	Capture full X-Forwarded-For header
+*	Add protection against malicious scripts; Extra check if file was really
+	uploaded
+*	Pass-thru page limit in return value of Cursor->paginate()
+*	Optimize code: Implement single-pass escaping
+*	Short circuit Jig->find() if source file is empty
+*	Bug fix: PHP globals passed by reference in hive() result (Issue #424)
+*	Bug fix: ZIP mime type incorrect behavior
+*	Bug fix: Jig->erase() filter malfunction
+*	Bug fix: Mongo->select() group
+*	Bug fix: Unknown bcrypt constant
+
+3.1.1 (13 October 2013)
+*	NEW: Support OpenID attribute exchange
+*	NEW: BAIL variable enables/disables continuance of execution on non-fatal
+	errors
+*	Deprecate BAIL in favor of HALT (default: FALSE)
+*	Add support for Oracle
+*	Mark cached queries in log (Feature Request #405)
+*	Implement Bcrypt->needs_reshash()
+*	Add entropy to SQL cache hash; Add uuid() method to DB backends
+*	Find real document root; Simplify debug paths
+*	Permit OpenID required fields to be declared as comma-separated string or
+	array
+*	Pass modified filename as argument to user-defined function in
+	Web->receive()
+*	Quote keys in optional SQL clauses (Issue #408)
+*	Allow UNLOAD to override fatal error detection (Issue #404)
+*	Mutex operator precedence error (Issue #406)
+*	Bug fix: exists() malfunction (Issue #401)
+*	Bug fix: Jig mapper triggers error when loading from CACHE (Issue #403)
+*	Bug fix: Array index check
+*	Bug fix: OpenID verified() return value
+*	Bug fix: Basket->find() should return a set of results (Issue #407);
+	Also implemented findone() for consistency with mappers
+*	Bug fix: PostgreSQL last insert ID (Issue #410)
+*	Bug fix: $port component URL overwritten by _socket()
+*	Bug fix: Calculation of elapsed time
+
+3.1.0 (20 August 2013)
+*	NEW: Web->filler() returns a chunk of text from the standard
+	Lorem Ipsum passage
+*	Change in behavior: Drop support for JSON serialization
+*	SQL->exec() now returns value of RETURNING clause
+*	Add support for $ttl argument in count() (Issue #393)
+*	Allow UI to be overridden by custom $path
+*	Return result of PDO primitives: begintransaction(), rollback(), and
+	commit()
+*	Full support for PHP 5.5
+*	Flush buffers only when DEBUG=0
+*	Support class->method, class::method, and lambda functions as
+	Web->basic() arguments
+*	Commit session on Basket->save()
+*	Optional enlargement in Image->resize()
+*	Support authentication on hosts running PHP-CGI
+*	Change visibility level of Cache properties
+*	Prevent ONERROR recursion
+*	Work around Apache pre-2.4 VirtualDocumentRoot bug
+*	Prioritize cURL in HTTP engine detection
+*	Bug fix: Minify tricky JS
+*	Bug fix: desktop() detection
+*	Bug fix: Double-slash on TEMP-relative path
+*	Bug fix: Cursor mapping of first() and last() records
+*	Bug fix: Premature end of Web->receive() on multiple files
+*	Bug fix: German umlaute to its corresponding grammatically-correct
+	equivalent
+
+3.0.9 (12 June 2013)
+*	NEW: Web->whois()
+*	NEW: Template <switch> <case> tags
+*	Improve CACHE consistency
+*	Case-insensitive MIME type detection
+*	Support pre-PHP 5.3.4 in Prefab->instance()
+*	Refactor isdesktop() and ismobile(); Add isbot()
+*	Add support for Markdown strike-through
+*	Work around ODBC's lack of quote() support
+*	Remove useless Prefab destructor
+*	Support multiple cache instances
+*	Bug fix: Underscores in OpenId keys mangled
+*	Refactor format()
+*	Numerous tweaks
+*	Bug fix: MongoId object not preserved
+*	Bug fix: Double-quotes included in lexicon() string (Issue #341)
+*	Bug fix: UTF-8 formatting mangled on Windows (Issue #342)
+*	Bug fix: Cache->load() error when CACHE is FALSE (Issue #344)
+*	Bug fix: send() ternary expression
+*	Bug fix: Country code constants
+
+3.0.8 (17 May 2013)
+*	NEW: Bcrypt lightweight hashing library\
+*	Return total number of records in superset in Cursor->paginate()
+*	ONERROR short-circuit (Enhancement #334)
+*	Apply quotes/backticks on DB identifiers
+*	Allow enabling/disabling of SQL log
+*	Normalize glob() behavior (Issue #330)
+*	Bug fix: mbstring 2-byte text truncation (Issue #325)
+*	Bug fix: Unsupported operand types (Issue #324)
+
+3.0.7 (2 May 2013)
+*	NEW: route() now allows an array of routing patterns as first argument;
+	support array as first argument of map()
+*	NEW: entropy() for calculating password strength (NIST 800-63)
+*	NEW: AGENT variable containing auto-detected HTTP user agent string
+*	NEW: ismobile() and isdesktop() methods
+*	NEW: Prefab class and descendants now accept constructor arguments
+*	Change in behavior: Cache->exists() now returns timestamp and TTL of
+	cache entry or FALSE if not found (Feature request #315)
+*	Preserve timestamp and TTL when updating cache entry (Feature request
+	#316)
+*	Improved currency formatting with C99 compliance
+*	Suppress unnecessary program halt at startup caused by misconfigured
+	server
+*	Add support for dashes in custom attribute names in templates
+*	Bug fix: Routing precedene (Issue #313)
+*	Bug fix: Remove Jig _id element from document property
+*	Bug fix: Web->rss() error when not enough items in the feed (Issue #299)
+*	Bug fix: Web engine fallback (Issue #300)
+*	Bug fix: <strong> and <em> formatting
+*	Bug fix: Text rendering of text with trailing punctuation (Issue #303)
+*	Bug fix: Incorrect regex in SMTP
+
+3.0.6 (31 Mar 2013)
+*	NEW: Image->crop()
+*	Modify documentation blocks for PHPDoc interoperability
+*	Allow user to control whether Base->rerouet() uses a permanent or
+	temporary redirect
+*	Allow JAR elements to be set individually
+*	Refactor DB\SQL\Mapper->insert() to cope with autoincrement fields
+*	Trigger error when captcha() font is missing
+*	Remove unnecessary markdown regex recursion
+*	Check for scalars instead of DB\SQL strings
+*	Implement more comprehensive diacritics table
+*	Add option for disabling 401 errors when basic auth() fails
+*	Add markdown syntax highlighting for Apache configuration
+*	Markdown->render() deprecated to remove dependency on UI variable;
+	Feature replaced by Markdown->convert() to enable translation from
+	markdown string to HTML
+*	Optimize factory() code of all data mappers
+*	Apply backticks on MySQL table names
+*	Bug fix: Routing failure when directory path contains a tilde (Issue #291)
+*	Bug fix: Incorrect markdown parsing of strong/em sequences and inline HTML
+*	Bug fix: Cached page not echoed (Issue #278)
+*	Bug fix: Object properties not escaped when rendering
+*	Bug fix: OpenID error response ignored
+*	Bug fix: memcache_get_extended_stats() timeout
+*	Bug fix: Base->set() doesn't pass TTL to Cache->set()
+*	Bug fix: Base->scrub() ignores pass-thru * argument (Issue #274)
+
+3.0.5 (16 Feb 2013)
+*	NEW: Markdown class with PHP, HTML, and .ini syntax highlighting support
+*	NEW: Options for caching of select() and find() results
+*	NEW: Web->acceptable()
+*	Add send() argument for forcing downloads
+*	Provide read() option for applying Unix LF as standard line ending
+*	Bypass lexicon() call if LANGUAGE is undefined
+*	Load fallback language dictionary if LANGUAGE is undefined
+*	map() now checks existence of class/methods for non-tokenized URLs
+*	Improve error reporting of non-existent Template methods
+*	Address output buffer issues on some servers
+*	Bug fix: Setting DEBUG to 0 won't suppress the stack trace when the
+	content type is application/json (Issue #257)
+*	Bug fix: Image dump/render additional arguments shifted
+*	Bug fix: ob_clean() causes buffer issues with zlib compression
+*	Bug fix: minify() fails when commenting CSS @ rules (Issue #251)
+*	Bug fix: Handling of commas inside quoted strings
+*	Bug fix: Glitch in stringify() handling of closures
+*	Bug fix: dry() in mappers returns TRUE despite being hydrated by
+	factory() (Issue #265)
+*	Bug fix: expect() not handling flags correctly
+*	Bug fix: weather() fails when server is unreachable
+
+3.0.4 (29 Jan 2013)
+*	NEW: Support for ICU/CLDR pluralization
+*	NEW: User-defined FALLBACK language
+*	NEW: minify() now recognizes CSS @import directives
+*	NEW: UTF->bom() returns byte order mark for UTF-8 encoding
+*	Expose SQL\Mapper->schema()
+*	Change in behavior: Send error response as JSON string if AJAX request is
+	detected
+*	Deprecated: afind*() methods
+*	Discard output buffer in favor of debug output
+*	Make _id available to Jig queries
+*	Magic class now implements ArrayAccess
+*	Abort execution on startup errors
+*	Suppress stack trace on DEBUG level 0
+*	Allow single = as equality operator in Jig query expressions
+*	Abort OpenID discovery if Web->request() fails
+*	Mimic PHP *RECURSION* in stringify()
+*	Modify Jig parser to allow wildcard-search using preg_match()
+*	Abort execution after error() execution
+*	Concatenate cached/uncached minify() iterations; Prevent spillover
+	caching of previous minify() result
+*	Work around obscure PHP session id regeneration bug
+*	Revise algorithm for Jig filter involving undefined fields (Issue #230)
+*	Use checkdnsrr() instead of gethostbyname() in DNSBL check
+*	Auto-adjust pagination to cursor boundaries
+*	Add Romanian diacritics
+*	Bug fix: Root namespace reference and sorting with undefined Jig fields
+*	Bug fix: Greedy receive() regex
+*	Bug fix: Default LANGUAGE always 'en'
+*	Bug fix: minify() hammers cache backend
+*	Bug fix: Previous values of primary keys not saved during factory()
+	instantiation
+*	Bug fix: Jig find() fails when search key is not present in all records
+*	Bug fix: Jig SORT_DESC (Issue #233)
+*	Bug fix: Error reporting (Issue #225)
+*	Bug fix: language() return value
+
+3.0.3 (29 Dec 2013)
+*	NEW: [ajax] and [sync] routing pattern modifiers
+*	NEW: Basket class (session-based pseudo-mapper, shopping cart, etc.)
+*	NEW: Test->message() method
+*	NEW: DB profiling via DB->log()
+*	NEW: Matrix->calendar()
+*	NEW: Audit->card() and Audit->mod10() for credit card verification
+*	NEW: Geo->weather()
+*	NEW: Base->relay() accepts comma-separated callbacks; but unlike
+	Base->chain(), result of previous callback becomes argument of the next
+*	Numerous performance tweaks
+*	Interoperability with new MongoClient class
+*	Web->request() now recognizes gzip and deflate encoding
+*	Differences in behavior of Web->request() engines rectified
+*	mutex() now uses an ID as argument (instead of filename to make it clear
+	that specified file is not the target being locked, but a primitive
+	cross-platform semaphore)
+*	DB\SQL\Mapper field _id now returned even in the absence of any
+	auto-increment field
+*	Magic class spinned off as a separate file
+*	ISO 3166-1 alpha-2 table updated
+*	Apache redirect emulation for PHP 5.4 CLI server mode
+*	Framework instance now passed as argument to any user-defined shutdown
+	function
+*	Cache engine now used as storage for Web->minify() output
+*	Flag added for enabling/disabling Image class filter history
+*	Bug fix: Trailing routing token consumes HTTP query
+*	Bug fix: LANGUAGE spills over to LOCALES setting
+*	Bug fix: Inconsistent dry() return value
+*	Bug fix: URL-decoding
+
+3.0.2 (23 Dec 2013)
+*	NEW: Syntax-highlighted stack traces via Base->highlight(); boolean
+	HIGHLIGHT global variable can be used to enable/disable this feature
+*	NEW: Template engine <ignore> tag
+*	NEW: Image->captcha()
+*	NEW: DNSBL-based spammer detection (ported from 2.x)
+*	NEW: paginate(), first(), and last() methods for data mappers
+*	NEW: X-HTTP-Method-Override header now recognized
+*	NEW: Base->chain() method for executing callbacks in succession
+*	NEW: HOST global variable; derived from either $_SERVER['SERVER_NAME'] or
+	gethostname()
+*	NEW: REALM global variable representing full canonical URI
+*	NEW: Auth plug-in
+*	NEW: Pingback plug-in (implements both Pingback 1.0 protocol client and
+	server)
+*	NEW: DEBUG verbosity can now reach up to level 3; Base->stringify() drills
+	down to object properties at this setting
+*	NEW: HTTP PATCH method added to recognized HTTP ReST methods
+*	Web->slug() now trims trailing dashes
+*	Web->request() now allows relative local URLs as argument
+*	Use of PARAMS in route handlers now unnecessary; framework now passes two
+	arguments to route handlers: the framework object instance and an array
+	containing the captured values of tokens in route patterns
+*	Standardized timeout settings among Web->request() backends
+*	Session IDs regenerated for additional security
+*	Automatic HTTP 404 responses by Base->call() now restricted to route
+	handlers
+*	Empty comments in ini-style files now parsed properly
+*	Use file_get_contents() in methods that don't involve high concurrency
+
+3.0.1 (14 Dec 2013)
+*	Major rewrite of much of the framework's core features

+ 621 - 0
lib/f3/COPYING

@@ -0,0 +1,621 @@
+GNU GENERAL PUBLIC LICENSE
+Version 3, 29 June 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+Everyone is permitted to copy and distribute verbatim copies
+of this license document, but changing it is not allowed.
+
+Preamble
+
+The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+TERMS AND CONDITIONS
+
+0. Definitions.
+
+"This License" refers to version 3 of the GNU General Public License.
+
+"Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+1. Source Code.
+
+The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+The Corresponding Source for a work in source code form is that
+same work.
+
+2. Basic Permissions.
+
+All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+a) The work must carry prominent notices stating that you modified
+it, and giving a relevant date.
+
+b) The work must carry prominent notices stating that it is
+released under this License and any conditions added under section
+7. This requirement modifies the requirement in section 4 to
+"keep intact all notices".
+
+c) You must license the entire work, as a whole, under this
+License to anyone who comes into possession of a copy. This
+License will therefore apply, along with any applicable section 7
+additional terms, to the whole of the work, and all its parts,
+regardless of how they are packaged. This License gives no
+permission to license the work in any other way, but it does not
+invalidate such permission if you have separately received it.
+
+d) If the work has interactive user interfaces, each must display
+Appropriate Legal Notices; however, if the Program has interactive
+interfaces that do not display Appropriate Legal Notices, your
+work need not make them do so.
+
+A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+a) Convey the object code in, or embodied in, a physical product
+(including a physical distribution medium), accompanied by the
+Corresponding Source fixed on a durable physical medium
+customarily used for software interchange.
+
+b) Convey the object code in, or embodied in, a physical product
+(including a physical distribution medium), accompanied by a
+written offer, valid for at least three years and valid for as
+long as you offer spare parts or customer support for that product
+model, to give anyone who possesses the object code either (1) a
+copy of the Corresponding Source for all the software in the
+product that is covered by this License, on a durable physical
+medium customarily used for software interchange, for a price no
+more than your reasonable cost of physically performing this
+conveying of source, or (2) access to copy the
+Corresponding Source from a network server at no charge.
+
+c) Convey individual copies of the object code with a copy of the
+written offer to provide the Corresponding Source. This
+alternative is allowed only occasionally and noncommercially, and
+only if you received the object code with such an offer, in accord
+with subsection 6b.
+
+d) Convey the object code by offering access from a designated
+place (gratis or for a charge), and offer equivalent access to the
+Corresponding Source in the same way through the same place at no
+further charge. You need not require recipients to copy the
+Corresponding Source along with the object code. If the place to
+copy the object code is a network server, the Corresponding Source
+may be on a different server (operated by you or a third party)
+that supports equivalent copying facilities, provided you maintain
+clear directions next to the object code saying where to find the
+Corresponding Source. Regardless of what server hosts the
+Corresponding Source, you remain obligated to ensure that it is
+available for as long as needed to satisfy these requirements.
+
+e) Convey the object code using peer-to-peer transmission, provided
+you inform other peers where the object code and Corresponding
+Source of the work are being offered to the general public at no
+charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+"Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+a) Disclaiming warranty or limiting liability differently from the
+terms of sections 15 and 16 of this License; or
+
+b) Requiring preservation of specified reasonable legal notices or
+author attributions in that material or in the Appropriate Legal
+Notices displayed by works containing it; or
+
+c) Prohibiting misrepresentation of the origin of that material, or
+requiring that modified versions of such material be marked in
+reasonable ways as different from the original version; or
+
+d) Limiting the use for publicity purposes of names of licensors or
+authors of the material; or
+
+e) Declining to grant rights under trademark law for use of some
+trade names, trademarks, or service marks; or
+
+f) Requiring indemnification of licensors and authors of that
+material by anyone who conveys the material (or modified versions of
+it) with contractual assumptions of liability to the recipient, for
+any liability that these contractual assumptions directly impose on
+those licensors and authors.
+
+All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+8. Termination.
+
+You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+13. Use with the GNU Affero General Public License.
+
+Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+END OF TERMS AND CONDITIONS

+ 191 - 0
lib/f3/audit.php

@@ -0,0 +1,191 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Data validator
+class Audit extends Prefab {
+
+	//@{ User agents
+	const
+		UA_Mobile='android|blackberry|phone|ipod|palm|windows\s+ce',
+		UA_Desktop='bsd|linux|os\s+[x9]|solaris|windows',
+		UA_Bot='bot|crawl|slurp|spider';
+	//@}
+
+	/**
+	*	Return TRUE if string is a valid URL
+	*	@return bool
+	*	@param $str string
+	**/
+	function url($str) {
+		return is_string(filter_var($str,FILTER_VALIDATE_URL));
+	}
+
+	/**
+	*	Return TRUE if string is a valid e-mail address;
+	*	Check DNS MX records if specified
+	*	@return bool
+	*	@param $str string
+	*	@param $mx boolean
+	**/
+	function email($str,$mx=TRUE) {
+		$hosts=[];
+		return is_string(filter_var($str,FILTER_VALIDATE_EMAIL)) &&
+			(!$mx || getmxrr(substr($str,strrpos($str,'@')+1),$hosts));
+	}
+
+	/**
+	*	Return TRUE if string is a valid IPV4 address
+	*	@return bool
+	*	@param $addr string
+	**/
+	function ipv4($addr) {
+		return (bool)filter_var($addr,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4);
+	}
+
+	/**
+	*	Return TRUE if string is a valid IPV6 address
+	*	@return bool
+	*	@param $addr string
+	**/
+	function ipv6($addr) {
+		return (bool)filter_var($addr,FILTER_VALIDATE_IP,FILTER_FLAG_IPV6);
+	}
+
+	/**
+	*	Return TRUE if IP address is within private range
+	*	@return bool
+	*	@param $addr string
+	**/
+	function isprivate($addr) {
+		return !(bool)filter_var($addr,FILTER_VALIDATE_IP,
+			FILTER_FLAG_IPV4|FILTER_FLAG_IPV6|FILTER_FLAG_NO_PRIV_RANGE);
+	}
+
+	/**
+	*	Return TRUE if IP address is within reserved range
+	*	@return bool
+	*	@param $addr string
+	**/
+	function isreserved($addr) {
+		return !(bool)filter_var($addr,FILTER_VALIDATE_IP,
+			FILTER_FLAG_IPV4|FILTER_FLAG_IPV6|FILTER_FLAG_NO_RES_RANGE);
+	}
+
+	/**
+	*	Return TRUE if IP address is neither private nor reserved
+	*	@return bool
+	*	@param $addr string
+	**/
+	function ispublic($addr) {
+		return (bool)filter_var($addr,FILTER_VALIDATE_IP,
+			FILTER_FLAG_IPV4|FILTER_FLAG_IPV6|
+			FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE);
+	}
+
+	/**
+	*	Return TRUE if user agent is a desktop browser
+	*	@return bool
+	*	@param $agent string
+	**/
+	function isdesktop($agent=NULL) {
+		if (!isset($agent))
+			$agent=Base::instance()->AGENT;
+		return (bool)preg_match('/('.self::UA_Desktop.')/i',$agent) &&
+			!$this->ismobile($agent);
+	}
+
+	/**
+	*	Return TRUE if user agent is a mobile device
+	*	@return bool
+	*	@param $agent string
+	**/
+	function ismobile($agent=NULL) {
+		if (!isset($agent))
+			$agent=Base::instance()->AGENT;
+		return (bool)preg_match('/('.self::UA_Mobile.')/i',$agent);
+	}
+
+	/**
+	*	Return TRUE if user agent is a Web bot
+	*	@return bool
+	*	@param $agent string
+	**/
+	function isbot($agent=NULL) {
+		if (!isset($agent))
+			$agent=Base::instance()->AGENT;
+		return (bool)preg_match('/('.self::UA_Bot.')/i',$agent);
+	}
+
+	/**
+	*	Return TRUE if specified ID has a valid (Luhn) Mod-10 check digit
+	*	@return bool
+	*	@param $id string
+	**/
+	function mod10($id) {
+		if (!ctype_digit($id))
+			return FALSE;
+		$id=strrev($id);
+		$sum=0;
+		for ($i=0,$l=strlen($id);$i<$l;$i++)
+			$sum+=$id[$i]+$i%2*(($id[$i]>4)*-4+$id[$i]%5);
+		return !($sum%10);
+	}
+
+	/**
+	*	Return credit card type if number is valid
+	*	@return string|FALSE
+	*	@param $id string
+	**/
+	function card($id) {
+		$id=preg_replace('/[^\d]/','',$id);
+		if ($this->mod10($id)) {
+			if (preg_match('/^3[47][0-9]{13}$/',$id))
+				return 'American Express';
+			if (preg_match('/^3(?:0[0-5]|[68][0-9])[0-9]{11}$/',$id))
+				return 'Diners Club';
+			if (preg_match('/^6(?:011|5[0-9][0-9])[0-9]{12}$/',$id))
+				return 'Discover';
+			if (preg_match('/^(?:2131|1800|35\d{3})\d{11}$/',$id))
+				return 'JCB';
+			if (preg_match('/^5[1-5][0-9]{14}$|'.
+				'^(222[1-9]|2[3-6]\d{2}|27[0-1]\d|2720)\d{12}$/',$id))
+				return 'MasterCard';
+			if (preg_match('/^4[0-9]{12}(?:[0-9]{3})?$/',$id))
+				return 'Visa';
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Return entropy estimate of a password (NIST 800-63)
+	*	@return int|float
+	*	@param $str string
+	**/
+	function entropy($str) {
+		$len=strlen($str);
+		return 4*min($len,1)+($len>1?(2*(min($len,8)-1)):0)+
+			($len>8?(1.5*(min($len,20)-8)):0)+($len>20?($len-20):0)+
+			6*(bool)(preg_match(
+				'/[A-Z].*?[0-9[:punct:]]|[0-9[:punct:]].*?[A-Z]/',$str));
+	}
+
+}

+ 249 - 0
lib/f3/auth.php

@@ -0,0 +1,249 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Authorization/authentication plug-in
+class Auth {
+
+	//@{ Error messages
+	const
+		E_LDAP='LDAP connection failure',
+		E_SMTP='SMTP connection failure';
+	//@}
+
+	protected
+		//! Auth storage
+		$storage,
+		//! Mapper object
+		$mapper,
+		//! Storage options
+		$args;
+
+	/**
+	*	Jig storage handler
+	*	@return bool
+	*	@param $id string
+	*	@param $pw string
+	*	@param $realm string
+	**/
+	protected function _jig($id,$pw,$realm) {
+		return (bool)
+			call_user_func_array(
+				[$this->mapper,'load'],
+				[
+					array_merge(
+						[
+							'@'.$this->args['id'].'==? AND '.
+							'@'.$this->args['pw'].'==?'.
+							(isset($this->args['realm'])?
+								(' AND @'.$this->args['realm'].'==?'):''),
+							$id,$pw
+						],
+						(isset($this->args['realm'])?[$realm]:[])
+					)
+				]
+			);
+	}
+
+	/**
+	*	MongoDB storage handler
+	*	@return bool
+	*	@param $id string
+	*	@param $pw string
+	*	@param $realm string
+	**/
+	protected function _mongo($id,$pw,$realm) {
+		return (bool)
+			$this->mapper->load(
+				[
+					$this->args['id']=>$id,
+					$this->args['pw']=>$pw
+				]+
+				(isset($this->args['realm'])?
+					[$this->args['realm']=>$realm]:[])
+			);
+	}
+
+	/**
+	*	SQL storage handler
+	*	@return bool
+	*	@param $id string
+	*	@param $pw string
+	*	@param $realm string
+	**/
+	protected function _sql($id,$pw,$realm) {
+		return (bool)
+			call_user_func_array(
+				[$this->mapper,'load'],
+				[
+					array_merge(
+						[
+							$this->args['id'].'=? AND '.
+							$this->args['pw'].'=?'.
+							(isset($this->args['realm'])?
+								(' AND '.$this->args['realm'].'=?'):''),
+							$id,$pw
+						],
+						(isset($this->args['realm'])?[$realm]:[])
+					)
+				]
+			);
+	}
+
+	/**
+	*	LDAP storage handler
+	*	@return bool
+	*	@param $id string
+	*	@param $pw string
+	**/
+	protected function _ldap($id,$pw) {
+		$port=(int)($this->args['port']?:389);
+		$filter=$this->args['filter']=$this->args['filter']?:"uid=".$id;
+		$this->args['attr']=$this->args['attr']?:["uid"];
+		array_walk($this->args['attr'],
+		function($attr)use(&$filter,$id) {
+			$filter=str_ireplace($attr."=*",$attr."=".$id,$filter);});
+		$dc=@ldap_connect($this->args['dc'],$port);
+		if ($dc &&
+			ldap_set_option($dc,LDAP_OPT_PROTOCOL_VERSION,3) &&
+			ldap_set_option($dc,LDAP_OPT_REFERRALS,0) &&
+			ldap_bind($dc,$this->args['rdn'],$this->args['pw']) &&
+			($result=ldap_search($dc,$this->args['base_dn'],
+				$filter,$this->args['attr'])) &&
+			ldap_count_entries($dc,$result) &&
+			($info=ldap_get_entries($dc,$result)) &&
+			$info['count']==1 &&
+			@ldap_bind($dc,$info[0]['dn'],$pw) &&
+			@ldap_close($dc)) {
+			return in_array($id,(array_map(function($value){return $value[0];},
+				array_intersect_key($info[0],
+					array_flip($this->args['attr'])))),TRUE);
+		}
+		user_error(self::E_LDAP,E_USER_ERROR);
+	}
+
+	/**
+	*	SMTP storage handler
+	*	@return bool
+	*	@param $id string
+	*	@param $pw string
+	**/
+	protected function _smtp($id,$pw) {
+		$socket=@fsockopen(
+			(strtolower($this->args['scheme'])=='ssl'?
+				'ssl://':'').$this->args['host'],
+				$this->args['port']);
+		$dialog=function($cmd=NULL) use($socket) {
+			if (!is_null($cmd))
+				fputs($socket,$cmd."\r\n");
+			$reply='';
+			while (!feof($socket) &&
+				($info=stream_get_meta_data($socket)) &&
+				!$info['timed_out'] && $str=fgets($socket,4096)) {
+				$reply.=$str;
+				if (preg_match('/(?:^|\n)\d{3} .+\r\n/s',
+					$reply))
+					break;
+			}
+			return $reply;
+		};
+		if ($socket) {
+			stream_set_blocking($socket,TRUE);
+			$dialog();
+			$fw=Base::instance();
+			$dialog('EHLO '.$fw->HOST);
+			if (strtolower($this->args['scheme'])=='tls') {
+				$dialog('STARTTLS');
+				stream_socket_enable_crypto(
+					$socket,TRUE,STREAM_CRYPTO_METHOD_TLS_CLIENT);
+				$dialog('EHLO '.$fw->HOST);
+			}
+			// Authenticate
+			$dialog('AUTH LOGIN');
+			$dialog(base64_encode($id));
+			$reply=$dialog(base64_encode($pw));
+			$dialog('QUIT');
+			fclose($socket);
+			return (bool)preg_match('/^235 /',$reply);
+		}
+		user_error(self::E_SMTP,E_USER_ERROR);
+	}
+
+	/**
+	*	Login auth mechanism
+	*	@return bool
+	*	@param $id string
+	*	@param $pw string
+	*	@param $realm string
+	**/
+	function login($id,$pw,$realm=NULL) {
+		return $this->{'_'.$this->storage}($id,$pw,$realm);
+	}
+
+	/**
+	*	HTTP basic auth mechanism
+	*	@return bool
+	*	@param $func callback
+	**/
+	function basic($func=NULL) {
+		$fw=Base::instance();
+		$realm=$fw->REALM;
+		$hdr=NULL;
+		if (isset($_SERVER['HTTP_AUTHORIZATION']))
+			$hdr=$_SERVER['HTTP_AUTHORIZATION'];
+		elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION']))
+			$hdr=$_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
+		if (!empty($hdr))
+			list($_SERVER['PHP_AUTH_USER'],$_SERVER['PHP_AUTH_PW'])=
+				explode(':',base64_decode(substr($hdr,6)));
+		if (isset($_SERVER['PHP_AUTH_USER'],$_SERVER['PHP_AUTH_PW']) &&
+			$this->login(
+				$_SERVER['PHP_AUTH_USER'],
+				$func?
+					$fw->call($func,$_SERVER['PHP_AUTH_PW']):
+					$_SERVER['PHP_AUTH_PW'],
+				$realm
+			))
+			return TRUE;
+		if (PHP_SAPI!='cli')
+			header('WWW-Authenticate: Basic realm="'.$realm.'"');
+		$fw->status(401);
+		return FALSE;
+	}
+
+	/**
+	*	Instantiate class
+	*	@return object
+	*	@param $storage string|object
+	*	@param $args array
+	**/
+	function __construct($storage,array $args=NULL) {
+		if (is_object($storage) && is_a($storage,'DB\Cursor')) {
+			$this->storage=$storage->dbtype();
+			$this->mapper=$storage;
+			unset($ref);
+		}
+		else
+			$this->storage=$storage;
+		$this->args=$args;
+	}
+
+}

+ 3453 - 0
lib/f3/base.php

@@ -0,0 +1,3453 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Factory class for single-instance objects
+abstract class Prefab {
+
+	/**
+	*	Return class instance
+	*	@return static
+	**/
+	static function instance() {
+		if (!Registry::exists($class=get_called_class())) {
+			$ref=new Reflectionclass($class);
+			$args=func_get_args();
+			Registry::set($class,
+				$args?$ref->newinstanceargs($args):new $class);
+		}
+		return Registry::get($class);
+	}
+
+}
+
+//! Base structure
+final class Base extends Prefab implements ArrayAccess {
+
+	//@{ Framework details
+	const
+		PACKAGE='Fat-Free Framework',
+		VERSION='3.6.4-Release';
+	//@}
+
+	//@{ HTTP status codes (RFC 2616)
+	const
+		HTTP_100='Continue',
+		HTTP_101='Switching Protocols',
+		HTTP_103='Early Hints',
+		HTTP_200='OK',
+		HTTP_201='Created',
+		HTTP_202='Accepted',
+		HTTP_203='Non-Authorative Information',
+		HTTP_204='No Content',
+		HTTP_205='Reset Content',
+		HTTP_206='Partial Content',
+		HTTP_300='Multiple Choices',
+		HTTP_301='Moved Permanently',
+		HTTP_302='Found',
+		HTTP_303='See Other',
+		HTTP_304='Not Modified',
+		HTTP_305='Use Proxy',
+		HTTP_307='Temporary Redirect',
+		HTTP_400='Bad Request',
+		HTTP_401='Unauthorized',
+		HTTP_402='Payment Required',
+		HTTP_403='Forbidden',
+		HTTP_404='Not Found',
+		HTTP_405='Method Not Allowed',
+		HTTP_406='Not Acceptable',
+		HTTP_407='Proxy Authentication Required',
+		HTTP_408='Request Timeout',
+		HTTP_409='Conflict',
+		HTTP_410='Gone',
+		HTTP_411='Length Required',
+		HTTP_412='Precondition Failed',
+		HTTP_413='Request Entity Too Large',
+		HTTP_414='Request-URI Too Long',
+		HTTP_415='Unsupported Media Type',
+		HTTP_416='Requested Range Not Satisfiable',
+		HTTP_417='Expectation Failed',
+		HTTP_500='Internal Server Error',
+		HTTP_501='Not Implemented',
+		HTTP_502='Bad Gateway',
+		HTTP_503='Service Unavailable',
+		HTTP_504='Gateway Timeout',
+		HTTP_505='HTTP Version Not Supported';
+	//@}
+
+	const
+		//! Mapped PHP globals
+		GLOBALS='GET|POST|COOKIE|REQUEST|SESSION|FILES|SERVER|ENV',
+		//! HTTP verbs
+		VERBS='GET|HEAD|POST|PUT|PATCH|DELETE|CONNECT|OPTIONS',
+		//! Default directory permissions
+		MODE=0755,
+		//! Syntax highlighting stylesheet
+		CSS='code.css';
+
+	//@{ Request types
+	const
+		REQ_SYNC=1,
+		REQ_AJAX=2,
+		REQ_CLI=4;
+	//@}
+
+	//@{ Error messages
+	const
+		E_Pattern='Invalid routing pattern: %s',
+		E_Named='Named route does not exist: %s',
+		E_Fatal='Fatal error: %s',
+		E_Open='Unable to open %s',
+		E_Routes='No routes specified',
+		E_Class='Invalid class %s',
+		E_Method='Invalid method %s',
+		E_Hive='Invalid hive key %s';
+	//@}
+
+	private
+		//! Globals
+		$hive,
+		//! Initial settings
+		$init,
+		//! Language lookup sequence
+		$languages,
+		//! Mutex locks
+		$locks=[],
+		//! Default fallback language
+		$fallback='en';
+
+	/**
+	*	Sync PHP global with corresponding hive key
+	*	@return array
+	*	@param $key string
+	**/
+	function sync($key) {
+		return $this->hive[$key]=&$GLOBALS['_'.$key];
+	}
+
+	/**
+	*	Return the parts of specified hive key
+	*	@return array
+	*	@param $key string
+	**/
+	private function cut($key) {
+		return preg_split('/\[\h*[\'"]?(.+?)[\'"]?\h*\]|(->)|\./',
+			$key,NULL,PREG_SPLIT_NO_EMPTY|PREG_SPLIT_DELIM_CAPTURE);
+	}
+
+	/**
+	*	Replace tokenized URL with available token values
+	*	@return string
+	*	@param $url array|string
+	*	@param $args array
+	**/
+	function build($url,$args=[]) {
+		$args+=$this->hive['PARAMS'];
+		if (is_array($url))
+			foreach ($url as &$var) {
+				$var=$this->build($var,$args);
+				unset($var);
+			}
+		else {
+			$i=0;
+			$url=preg_replace_callback('/@(\w+)|(\*)/',
+				function($match) use(&$i,$args) {
+					if (isset($match[1]) &&
+						array_key_exists($match[1],$args))
+						return $args[$match[1]];
+					if (isset($match[2]) &&
+						array_key_exists($match[2],$args)) {
+						if (!is_array($args[$match[2]]))
+							return $args[$match[2]];
+						$i++;
+						return $args[$match[2]][$i-1];
+					}
+					return $match[0];
+				},$url);
+		}
+		return $url;
+	}
+
+	/**
+	*	Parse string containing key-value pairs
+	*	@return array
+	*	@param $str string
+	**/
+	function parse($str) {
+		preg_match_all('/(\w+|\*)\h*=\h*(?:\[(.+?)\]|(.+?))(?=,|$)/',
+			$str,$pairs,PREG_SET_ORDER);
+		$out=[];
+		foreach ($pairs as $pair)
+			if ($pair[2]) {
+				$out[$pair[1]]=[];
+				foreach (explode(',',$pair[2]) as $val)
+					array_push($out[$pair[1]],$val);
+			}
+			else
+				$out[$pair[1]]=trim($pair[3]);
+		return $out;
+	}
+
+	/**
+	 * cast string variable to php type or constant
+	 * @param $val
+	 * @return mixed
+	 */
+	function cast($val) {
+		if (is_numeric($val))
+			return $val+0;
+		$val=trim($val);
+		if (preg_match('/^\w+$/i',$val) && defined($val))
+			return constant($val);
+		return $val;
+	}
+
+	/**
+	*	Convert JS-style token to PHP expression
+	*	@return string
+	*	@param $str string
+	**/
+	function compile($str) {
+		return preg_replace_callback(
+			'/(?<!\w)@(\w+(?:(?:\->|::)\w+)?)'.
+			'((?:\.\w+|\[(?:(?:[^\[\]]*|(?R))*)\]|(?:\->|::)\w+|\()*)/',
+			function($expr) {
+				$str='$'.$expr[1];
+				if (isset($expr[2]))
+					$str.=preg_replace_callback(
+						'/\.(\w+)(\()?|\[((?:[^\[\]]*|(?R))*)\]/',
+						function($sub) {
+							if (empty($sub[2])) {
+								if (ctype_digit($sub[1]))
+									$sub[1]=(int)$sub[1];
+								$out='['.
+									(isset($sub[3])?
+										$this->compile($sub[3]):
+										var_export($sub[1],TRUE)).
+								']';
+							}
+							else
+								$out=function_exists($sub[1])?
+									$sub[0]:
+									('['.var_export($sub[1],TRUE).']'.$sub[2]);
+							return $out;
+						},
+						$expr[2]
+					);
+				return $str;
+			},
+			$str
+		);
+	}
+
+	/**
+	*	Get hive key reference/contents; Add non-existent hive keys,
+	*	array elements, and object properties by default
+	*	@return mixed
+	*	@param $key string
+	*	@param $add bool
+	*	@param $var mixed
+	**/
+	function &ref($key,$add=TRUE,&$var=NULL) {
+		$null=NULL;
+		$parts=$this->cut($key);
+		if ($parts[0]=='SESSION') {
+			if (!headers_sent() && session_status()!=PHP_SESSION_ACTIVE)
+				session_start();
+			$this->sync('SESSION');
+		}
+		elseif (!preg_match('/^\w+$/',$parts[0]))
+			user_error(sprintf(self::E_Hive,$this->stringify($key)),
+				E_USER_ERROR);
+		if (is_null($var)) {
+			if ($add)
+				$var=&$this->hive;
+			else
+				$var=$this->hive;
+		}
+		$obj=FALSE;
+		foreach ($parts as $part)
+			if ($part=='->')
+				$obj=TRUE;
+			elseif ($obj) {
+				$obj=FALSE;
+				if (!is_object($var))
+					$var=new stdclass;
+				if ($add || property_exists($var,$part))
+					$var=&$var->$part;
+				else {
+					$var=&$null;
+					break;
+				}
+			}
+			else {
+				if (!is_array($var))
+					$var=[];
+				if ($add || array_key_exists($part,$var))
+					$var=&$var[$part];
+				else {
+					$var=&$null;
+					break;
+				}
+			}
+		return $var;
+	}
+
+	/**
+	*	Return TRUE if hive key is set
+	*	(or return timestamp and TTL if cached)
+	*	@return bool
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	function exists($key,&$val=NULL) {
+		$val=$this->ref($key,FALSE);
+		return isset($val)?
+			TRUE:
+			(Cache::instance()->exists($this->hash($key).'.var',$val)?:FALSE);
+	}
+
+	/**
+	*	Return TRUE if hive key is empty and not cached
+	*	@param $key string
+	*	@param $val mixed
+	*	@return bool
+	**/
+	function devoid($key,&$val=NULL) {
+		$val=$this->ref($key,FALSE);
+		return empty($val) &&
+			(!Cache::instance()->exists($this->hash($key).'.var',$val) ||
+				!$val);
+	}
+
+	/**
+	*	Bind value to hive key
+	*	@return mixed
+	*	@param $key string
+	*	@param $val mixed
+	*	@param $ttl int
+	**/
+	function set($key,$val,$ttl=0) {
+		$time=(int)$this->hive['TIME'];
+		if (preg_match('/^(GET|POST|COOKIE)\b(.+)/',$key,$expr)) {
+			$this->set('REQUEST'.$expr[2],$val);
+			if ($expr[1]=='COOKIE') {
+				$parts=$this->cut($key);
+				$jar=$this->unserialize($this->serialize($this->hive['JAR']));
+				unset($jar['lifetime']);
+				if (isset($_COOKIE[$parts[1]]))
+					call_user_func_array('setcookie',
+						array_merge([$parts[1],NULL],['expire'=>0]+$jar));
+				if ($ttl)
+					$jar['expire']=$time+$ttl;
+				call_user_func_array('setcookie',[$parts[1],$val]+$jar);
+				$_COOKIE[$parts[1]]=$val;
+				return $val;
+			}
+		}
+		else switch ($key) {
+		case 'CACHE':
+			$val=Cache::instance()->load($val);
+			break;
+		case 'ENCODING':
+			ini_set('default_charset',$val);
+			if (extension_loaded('mbstring'))
+				mb_internal_encoding($val);
+			break;
+		case 'FALLBACK':
+			$this->fallback=$val;
+			$lang=$this->language($this->hive['LANGUAGE']);
+		case 'LANGUAGE':
+			if (!isset($lang))
+				$val=$this->language($val);
+			$lex=$this->lexicon($this->hive['LOCALES'],$ttl);
+		case 'LOCALES':
+			if (isset($lex) || $lex=$this->lexicon($val,$ttl))
+				foreach ($lex as $dt=>$dd) {
+					$ref=&$this->ref($this->hive['PREFIX'].$dt);
+					$ref=$dd;
+					unset($ref);
+				}
+			break;
+		case 'TZ':
+			date_default_timezone_set($val);
+			break;
+		}
+		$ref=&$this->ref($key);
+		$ref=$val;
+		if (preg_match('/^JAR\b/',$key)) {
+			if ($key=='JAR.lifetime')
+				$this->set('JAR.expire',$val==0?0:
+					(is_int($val)?$time+$val:strtotime($val)));
+			else {
+				if ($key=='JAR.expire')
+					$this->hive['JAR']['lifetime']=max(0,$val-$time);
+				$jar=$this->unserialize($this->serialize($this->hive['JAR']));
+				unset($jar['expire']);
+				if (!headers_sent() && session_status()!=PHP_SESSION_ACTIVE)
+					call_user_func_array('session_set_cookie_params',$jar);
+			}
+		}
+		if ($ttl)
+			// Persist the key-value pair
+			Cache::instance()->set($this->hash($key).'.var',$val,$ttl);
+		return $ref;
+	}
+
+	/**
+	*	Retrieve contents of hive key
+	*	@return mixed
+	*	@param $key string
+	*	@param $args string|array
+	**/
+	function get($key,$args=NULL) {
+		if (is_string($val=$this->ref($key,FALSE)) && !is_null($args))
+			return call_user_func_array(
+				[$this,'format'],
+				array_merge([$val],is_array($args)?$args:[$args])
+			);
+		if (is_null($val)) {
+			// Attempt to retrieve from cache
+			if (Cache::instance()->exists($this->hash($key).'.var',$data))
+				return $data;
+		}
+		return $val;
+	}
+
+	/**
+	*	Unset hive key
+	*	@return NULL
+	*	@param $key string
+	**/
+	function clear($key) {
+		// Normalize array literal
+		$cache=Cache::instance();
+		$parts=$this->cut($key);
+		if ($key=='CACHE')
+			// Clear cache contents
+			$cache->reset();
+		elseif (preg_match('/^(GET|POST|COOKIE)\b(.+)/',$key,$expr)) {
+			$this->clear('REQUEST'.$expr[2]);
+			if ($expr[1]=='COOKIE') {
+				$parts=$this->cut($key);
+				$jar=$this->hive['JAR'];
+				unset($jar['lifetime']);
+				$jar['expire']=0;
+				call_user_func_array('setcookie',
+					array_merge([$parts[1],NULL],$jar));
+				unset($_COOKIE[$parts[1]]);
+			}
+		}
+		elseif ($parts[0]=='SESSION') {
+			if (!headers_sent() && session_status()!=PHP_SESSION_ACTIVE)
+				session_start();
+			if (empty($parts[1])) {
+				// End session
+				session_unset();
+				session_destroy();
+				$this->clear('COOKIE.'.session_name());
+			}
+			$this->sync('SESSION');
+		}
+		if (!isset($parts[1]) && array_key_exists($parts[0],$this->init))
+			// Reset global to default value
+			$this->hive[$parts[0]]=$this->init[$parts[0]];
+		else {
+			eval('unset('.$this->compile('@this->hive.'.$key).');');
+			if ($parts[0]=='SESSION') {
+				session_commit();
+				session_start();
+			}
+			if ($cache->exists($hash=$this->hash($key).'.var'))
+				// Remove from cache
+				$cache->clear($hash);
+		}
+	}
+
+	/**
+	*	Return TRUE if hive variable is 'on'
+	*	@return bool
+	*	@param $key string
+	**/
+	function checked($key) {
+		$ref=&$this->ref($key);
+		return $ref=='on';
+	}
+
+	/**
+	*	Return TRUE if property has public visibility
+	*	@return bool
+	*	@param $obj object
+	*	@param $key string
+	**/
+	function visible($obj,$key) {
+		if (property_exists($obj,$key)) {
+			$ref=new ReflectionProperty(get_class($obj),$key);
+			$out=$ref->ispublic();
+			unset($ref);
+			return $out;
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Multi-variable assignment using associative array
+	*	@return NULL
+	*	@param $vars array
+	*	@param $prefix string
+	*	@param $ttl int
+	**/
+	function mset(array $vars,$prefix='',$ttl=0) {
+		foreach ($vars as $key=>$val)
+			$this->set($prefix.$key,$val,$ttl);
+	}
+
+	/**
+	*	Publish hive contents
+	*	@return array
+	**/
+	function hive() {
+		return $this->hive;
+	}
+
+	/**
+	*	Copy contents of hive variable to another
+	*	@return mixed
+	*	@param $src string
+	*	@param $dst string
+	**/
+	function copy($src,$dst) {
+		$ref=&$this->ref($dst);
+		return $ref=$this->ref($src,FALSE);
+	}
+
+	/**
+	*	Concatenate string to hive string variable
+	*	@return string
+	*	@param $key string
+	*	@param $val string
+	**/
+	function concat($key,$val) {
+		$ref=&$this->ref($key);
+		$ref.=$val;
+		return $ref;
+	}
+
+	/**
+	*	Swap keys and values of hive array variable
+	*	@return array
+	*	@param $key string
+	*	@public
+	**/
+	function flip($key) {
+		$ref=&$this->ref($key);
+		return $ref=array_combine(array_values($ref),array_keys($ref));
+	}
+
+	/**
+	*	Add element to the end of hive array variable
+	*	@return mixed
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	function push($key,$val) {
+		$ref=&$this->ref($key);
+		$ref[]=$val;
+		return $val;
+	}
+
+	/**
+	*	Remove last element of hive array variable
+	*	@return mixed
+	*	@param $key string
+	**/
+	function pop($key) {
+		$ref=&$this->ref($key);
+		return array_pop($ref);
+	}
+
+	/**
+	*	Add element to the beginning of hive array variable
+	*	@return mixed
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	function unshift($key,$val) {
+		$ref=&$this->ref($key);
+		array_unshift($ref,$val);
+		return $val;
+	}
+
+	/**
+	*	Remove first element of hive array variable
+	*	@return mixed
+	*	@param $key string
+	**/
+	function shift($key) {
+		$ref=&$this->ref($key);
+		return array_shift($ref);
+	}
+
+	/**
+	*	Merge array with hive array variable
+	*	@return array
+	*	@param $key string
+	*	@param $src string|array
+	*	@param $keep bool
+	**/
+	function merge($key,$src,$keep=FALSE) {
+		$ref=&$this->ref($key);
+		if (!$ref)
+			$ref=[];
+		$out=array_merge($ref,is_string($src)?$this->hive[$src]:$src);
+		if ($keep)
+			$ref=$out;
+		return $out;
+	}
+
+	/**
+	*	Extend hive array variable with default values from $src
+	*	@return array
+	*	@param $key string
+	*	@param $src string|array
+	*	@param $keep bool
+	**/
+	function extend($key,$src,$keep=FALSE) {
+		$ref=&$this->ref($key);
+		if (!$ref)
+			$ref=[];
+		$out=array_replace_recursive(
+			is_string($src)?$this->hive[$src]:$src,$ref);
+		if ($keep)
+			$ref=$out;
+		return $out;
+	}
+
+	/**
+	*	Convert backslashes to slashes
+	*	@return string
+	*	@param $str string
+	**/
+	function fixslashes($str) {
+		return $str?strtr($str,'\\','/'):$str;
+	}
+
+	/**
+	*	Split comma-, semi-colon, or pipe-separated string
+	*	@return array
+	*	@param $str string
+	*	@param $noempty bool
+	**/
+	function split($str,$noempty=TRUE) {
+		return array_map('trim',
+			preg_split('/[,;|]/',$str,0,$noempty?PREG_SPLIT_NO_EMPTY:0));
+	}
+
+	/**
+	*	Convert PHP expression/value to compressed exportable string
+	*	@return string
+	*	@param $arg mixed
+	*	@param $stack array
+	**/
+	function stringify($arg,array $stack=NULL) {
+		if ($stack) {
+			foreach ($stack as $node)
+				if ($arg===$node)
+					return '*RECURSION*';
+		}
+		else
+			$stack=[];
+		switch (gettype($arg)) {
+			case 'object':
+				$str='';
+				foreach (get_object_vars($arg) as $key=>$val)
+					$str.=($str?',':'').
+						var_export($key,TRUE).'=>'.
+						$this->stringify($val,
+							array_merge($stack,[$arg]));
+				return get_class($arg).'::__set_state(['.$str.'])';
+			case 'array':
+				$str='';
+				$num=isset($arg[0]) &&
+					ctype_digit(implode('',array_keys($arg)));
+				foreach ($arg as $key=>$val)
+					$str.=($str?',':'').
+						($num?'':(var_export($key,TRUE).'=>')).
+						$this->stringify($val,array_merge($stack,[$arg]));
+				return '['.$str.']';
+			default:
+				return var_export($arg,TRUE);
+		}
+	}
+
+	/**
+	*	Flatten array values and return as CSV string
+	*	@return string
+	*	@param $args array
+	**/
+	function csv(array $args) {
+		return implode(',',array_map('stripcslashes',
+			array_map([$this,'stringify'],$args)));
+	}
+
+	/**
+	*	Convert snakecase string to camelcase
+	*	@return string
+	*	@param $str string
+	**/
+	function camelcase($str) {
+		return preg_replace_callback(
+			'/_(\pL)/u',
+			function($match) {
+				return strtoupper($match[1]);
+			},
+			$str
+		);
+	}
+
+	/**
+	*	Convert camelcase string to snakecase
+	*	@return string
+	*	@param $str string
+	**/
+	function snakecase($str) {
+		return strtolower(preg_replace('/(?!^)\p{Lu}/u','_\0',$str));
+	}
+
+	/**
+	*	Return -1 if specified number is negative, 0 if zero,
+	*	or 1 if the number is positive
+	*	@return int
+	*	@param $num mixed
+	**/
+	function sign($num) {
+		return $num?($num/abs($num)):0;
+	}
+
+	/**
+	*	Extract values of array whose keys start with the given prefix
+	*	@return array
+	*	@param $arr array
+	*	@param $prefix string
+	**/
+	function extract($arr,$prefix) {
+		$out=[];
+		foreach (preg_grep('/^'.preg_quote($prefix,'/').'/',array_keys($arr))
+			as $key)
+			$out[substr($key,strlen($prefix))]=$arr[$key];
+		return $out;
+	}
+
+	/**
+	*	Convert class constants to array
+	*	@return array
+	*	@param $class object|string
+	*	@param $prefix string
+	**/
+	function constants($class,$prefix='') {
+		$ref=new ReflectionClass($class);
+		return $this->extract($ref->getconstants(),$prefix);
+	}
+
+	/**
+	*	Generate 64bit/base36 hash
+	*	@return string
+	*	@param $str
+	**/
+	function hash($str) {
+		return str_pad(base_convert(
+			substr(sha1($str),-16),16,36),11,'0',STR_PAD_LEFT);
+	}
+
+	/**
+	*	Return Base64-encoded equivalent
+	*	@return string
+	*	@param $data string
+	*	@param $mime string
+	**/
+	function base64($data,$mime) {
+		return 'data:'.$mime.';base64,'.base64_encode($data);
+	}
+
+	/**
+	*	Convert special characters to HTML entities
+	*	@return string
+	*	@param $str string
+	**/
+	function encode($str) {
+		return @htmlspecialchars($str,$this->hive['BITMASK'],
+			$this->hive['ENCODING'])?:$this->scrub($str);
+	}
+
+	/**
+	*	Convert HTML entities back to characters
+	*	@return string
+	*	@param $str string
+	**/
+	function decode($str) {
+		return htmlspecialchars_decode($str,$this->hive['BITMASK']);
+	}
+
+	/**
+	*	Invoke callback recursively for all data types
+	*	@return mixed
+	*	@param $arg mixed
+	*	@param $func callback
+	*	@param $stack array
+	**/
+	function recursive($arg,$func,$stack=[]) {
+		if ($stack) {
+			foreach ($stack as $node)
+				if ($arg===$node)
+					return $arg;
+		}
+		switch (gettype($arg)) {
+			case 'object':
+				$ref=new ReflectionClass($arg);
+				if ($ref->iscloneable()) {
+					$arg=clone($arg);
+					$cast=is_a($arg,'IteratorAggregate')?
+						iterator_to_array($arg):get_object_vars($arg);
+					foreach ($cast as $key=>$val)
+						$arg->$key=$this->recursive(
+							$val,$func,array_merge($stack,[$arg]));
+				}
+				return $arg;
+			case 'array':
+				$copy=[];
+				foreach ($arg as $key=>$val)
+					$copy[$key]=$this->recursive($val,$func,
+						array_merge($stack,[$arg]));
+				return $copy;
+		}
+		return $func($arg);
+	}
+
+	/**
+	*	Remove HTML tags (except those enumerated) and non-printable
+	*	characters to mitigate XSS/code injection attacks
+	*	@return mixed
+	*	@param $arg mixed
+	*	@param $tags string
+	**/
+	function clean($arg,$tags=NULL) {
+		return $this->recursive($arg,
+			function($val) use($tags) {
+				if ($tags!='*')
+					$val=trim(strip_tags($val,
+						'<'.implode('><',$this->split($tags)).'>'));
+				return trim(preg_replace(
+					'/[\x00-\x08\x0B\x0C\x0E-\x1F]/','',$val));
+			}
+		);
+	}
+
+	/**
+	*	Similar to clean(), except that variable is passed by reference
+	*	@return mixed
+	*	@param $var mixed
+	*	@param $tags string
+	**/
+	function scrub(&$var,$tags=NULL) {
+		return $var=$this->clean($var,$tags);
+	}
+
+	/**
+	*	Return locale-aware formatted string
+	*	@return string
+	**/
+	function format() {
+		$args=func_get_args();
+		$val=array_shift($args);
+		// Get formatting rules
+		$conv=localeconv();
+		return preg_replace_callback(
+			'/\{\s*(?P<pos>\d+)\s*(?:,\s*(?P<type>\w+)\s*'.
+			'(?:,\s*(?P<mod>(?:\w+(?:\s*\{.+?\}\s*,?\s*)?)*)'.
+			'(?:,\s*(?P<prop>.+?))?)?)?\s*\}/',
+			function($expr) use($args,$conv) {
+				extract($expr);
+				extract($conv);
+				if (!array_key_exists($pos,$args))
+					return $expr[0];
+				if (isset($type)) {
+					if (isset($this->hive['FORMATS'][$type]))
+						return $this->call($this->hive['FORMATS'][$type],
+							[$args[$pos],isset($mod)?$mod:null,isset($prop)?$prop:null]);
+					switch ($type) {
+						case 'plural':
+							preg_match_all('/(?<tag>\w+)'.
+								'(?:\s*\{\s*(?<data>.+?)\s*\})/',
+								$mod,$matches,PREG_SET_ORDER);
+							$ord=['zero','one','two'];
+							foreach ($matches as $match) {
+								extract($match);
+								if (isset($ord[$args[$pos]]) &&
+									$tag==$ord[$args[$pos]] || $tag=='other')
+									return str_replace('#',$args[$pos],$data);
+							}
+						case 'number':
+							if (isset($mod))
+								switch ($mod) {
+									case 'integer':
+										return number_format(
+											$args[$pos],0,'',$thousands_sep);
+									case 'currency':
+										$int=$cstm=FALSE;
+										if (isset($prop) &&
+											$cstm=!$int=($prop=='int'))
+											$currency_symbol=$prop;
+										if (!$cstm &&
+											function_exists('money_format'))
+											return money_format(
+												'%'.($int?'i':'n'),$args[$pos]);
+										$fmt=[
+											0=>'(nc)',1=>'(n c)',
+											2=>'(nc)',10=>'+nc',
+											11=>'+n c',12=>'+ nc',
+											20=>'nc+',21=>'n c+',
+											22=>'nc +',30=>'n+c',
+											31=>'n +c',32=>'n+ c',
+											40=>'nc+',41=>'n c+',
+											42=>'nc +',100=>'(cn)',
+											101=>'(c n)',102=>'(cn)',
+											110=>'+cn',111=>'+c n',
+											112=>'+ cn',120=>'cn+',
+											121=>'c n+',122=>'cn +',
+											130=>'+cn',131=>'+c n',
+											132=>'+ cn',140=>'c+n',
+											141=>'c+ n',142=>'c +n'
+										];
+										if ($args[$pos]<0) {
+											$sgn=$negative_sign;
+											$pre='n';
+										}
+										else {
+											$sgn=$positive_sign;
+											$pre='p';
+										}
+										return str_replace(
+											['+','n','c'],
+											[$sgn,number_format(
+												abs($args[$pos]),
+												$frac_digits,
+												$decimal_point,
+												$thousands_sep),
+												$int?$int_curr_symbol
+													:$currency_symbol],
+											$fmt[(int)(
+												(${$pre.'_cs_precedes'}%2).
+												(${$pre.'_sign_posn'}%5).
+												(${$pre.'_sep_by_space'}%3)
+											)]
+										);
+									case 'percent':
+										return number_format(
+											$args[$pos]*100,0,$decimal_point,
+											$thousands_sep).'%';
+								}
+							return number_format(
+								$args[$pos],isset($prop)?$prop:2,
+								$decimal_point,$thousands_sep);
+						case 'date':
+							if (empty($mod) || $mod=='short')
+								$prop='%x';
+							elseif ($mod=='long')
+								$prop='%A, %d %B %Y';
+							return strftime($prop,$args[$pos]);
+						case 'time':
+							if (empty($mod) || $mod=='short')
+								$prop='%X';
+							return strftime($prop,$args[$pos]);
+						default:
+							return $expr[0];
+					}
+				}
+				return $args[$pos];
+			},
+			$val
+		);
+	}
+
+	/**
+	*	Assign/auto-detect language
+	*	@return string
+	*	@param $code string
+	**/
+	function language($code) {
+		$code=preg_replace('/\h+|;q=[0-9.]+/','',$code);
+		$code.=($code?',':'').$this->fallback;
+		$this->languages=[];
+		foreach (array_reverse(explode(',',$code)) as $lang)
+			if (preg_match('/^(\w{2})(?:-(\w{2}))?\b/i',$lang,$parts)) {
+				// Generic language
+				array_unshift($this->languages,$parts[1]);
+				if (isset($parts[2])) {
+					// Specific language
+					$parts[0]=$parts[1].'-'.($parts[2]=strtoupper($parts[2]));
+					array_unshift($this->languages,$parts[0]);
+				}
+			}
+		$this->languages=array_unique($this->languages);
+		$locales=[];
+		$windows=preg_match('/^win/i',PHP_OS);
+		// Work around PHP's Turkish locale bug
+		foreach (preg_grep('/^(?!tr)/i',$this->languages) as $locale) {
+			if ($windows) {
+				$parts=explode('-',$locale);
+				$locale=@constant('ISO::LC_'.$parts[0]);
+				if (isset($parts[1]) &&
+					$country=@constant('ISO::CC_'.strtolower($parts[1])))
+					$locale.='-'.$country;
+			}
+			$locale=str_replace('-','_',$locale);
+			$locales[]=$locale.'.'.ini_get('default_charset');
+			$locales[]=$locale;
+		}
+		setlocale(LC_ALL,$locales);
+		return $this->hive['LANGUAGE']=implode(',',$this->languages);
+	}
+
+	/**
+	*	Return lexicon entries
+	*	@return array
+	*	@param $path string
+	*	@param $ttl int
+	**/
+	function lexicon($path,$ttl=0) {
+		$languages=$this->languages?:explode(',',$this->fallback);
+		$cache=Cache::instance();
+		if ($cache->exists(
+			$hash=$this->hash(implode(',',$languages)).'.dic',$lex))
+			return $lex;
+		$lex=[];
+		foreach ($languages as $lang)
+			foreach ($this->split($path) as $dir)
+				if ((is_file($file=($base=$dir.$lang).'.php') ||
+					is_file($file=$base.'.php')) &&
+					is_array($dict=require($file)))
+					$lex+=$dict;
+				elseif (is_file($file=$base.'.ini')) {
+					preg_match_all(
+						'/(?<=^|\n)(?:'.
+							'\[(?<prefix>.+?)\]|'.
+							'(?<lval>[^\h\r\n;].*?)\h*=\h*'.
+							'(?<rval>(?:\\\\\h*\r?\n|.+?)*)'.
+						')(?=\r?\n|$)/',
+						$this->read($file),$matches,PREG_SET_ORDER);
+					if ($matches) {
+						$prefix='';
+						foreach ($matches as $match)
+							if ($match['prefix'])
+								$prefix=$match['prefix'].'.';
+							elseif (!array_key_exists(
+								$key=$prefix.$match['lval'],$lex))
+								$lex[$key]=trim(preg_replace(
+									'/\\\\\h*\r?\n/',"\n",$match['rval']));
+					}
+				}
+		if ($ttl)
+			$cache->set($hash,$lex,$ttl);
+		return $lex;
+	}
+
+	/**
+	*	Return string representation of PHP value
+	*	@return string
+	*	@param $arg mixed
+	**/
+	function serialize($arg) {
+		switch (strtolower($this->hive['SERIALIZER'])) {
+			case 'igbinary':
+				return igbinary_serialize($arg);
+			default:
+				return serialize($arg);
+		}
+	}
+
+	/**
+	*	Return PHP value derived from string
+	*	@return string
+	*	@param $arg mixed
+	**/
+	function unserialize($arg) {
+		switch (strtolower($this->hive['SERIALIZER'])) {
+			case 'igbinary':
+				return igbinary_unserialize($arg);
+			default:
+				return unserialize($arg);
+		}
+	}
+
+	/**
+	*	Send HTTP status header; Return text equivalent of status code
+	*	@return string
+	*	@param $code int
+	**/
+	function status($code) {
+		$reason=@constant('self::HTTP_'.$code);
+		if (!$this->hive['CLI'] && !headers_sent())
+			header($_SERVER['SERVER_PROTOCOL'].' '.$code.' '.$reason);
+		return $reason;
+	}
+
+	/**
+	*	Send cache metadata to HTTP client
+	*	@return NULL
+	*	@param $secs int
+	**/
+	function expire($secs=0) {
+		if (!$this->hive['CLI'] && !headers_sent()) {
+			$secs=(int)$secs;
+			if ($this->hive['PACKAGE'])
+				header('X-Powered-By: '.$this->hive['PACKAGE']);
+			if ($this->hive['XFRAME'])
+				header('X-Frame-Options: '.$this->hive['XFRAME']);
+			header('X-XSS-Protection: 1; mode=block');
+			header('X-Content-Type-Options: nosniff');
+			if ($this->hive['VERB']=='GET' && $secs) {
+				$time=microtime(TRUE);
+				header_remove('Pragma');
+				header('Cache-Control: max-age='.$secs);
+				header('Expires: '.gmdate('r',$time+$secs));
+				header('Last-Modified: '.gmdate('r'));
+			}
+			else {
+				header('Pragma: no-cache');
+				header('Cache-Control: no-cache, no-store, must-revalidate');
+				header('Expires: '.gmdate('r',0));
+			}
+		}
+	}
+
+	/**
+	*	Return HTTP user agent
+	*	@return string
+	**/
+	function agent() {
+		$headers=$this->hive['HEADERS'];
+		return isset($headers['X-Operamini-Phone-UA'])?
+			$headers['X-Operamini-Phone-UA']:
+			(isset($headers['X-Skyfire-Phone'])?
+				$headers['X-Skyfire-Phone']:
+				(isset($headers['User-Agent'])?
+					$headers['User-Agent']:''));
+	}
+
+	/**
+	*	Return TRUE if XMLHttpRequest detected
+	*	@return bool
+	**/
+	function ajax() {
+		$headers=$this->hive['HEADERS'];
+		return isset($headers['X-Requested-With']) &&
+			$headers['X-Requested-With']=='XMLHttpRequest';
+	}
+
+	/**
+	*	Sniff IP address
+	*	@return string
+	**/
+	function ip() {
+		$headers=$this->hive['HEADERS'];
+		return isset($headers['Client-IP'])?
+			$headers['Client-IP']:
+			(isset($headers['X-Forwarded-For'])?
+				explode(',',$headers['X-Forwarded-For'])[0]:
+				(isset($_SERVER['REMOTE_ADDR'])?
+					$_SERVER['REMOTE_ADDR']:''));
+	}
+
+	/**
+	*	Return filtered stack trace as a formatted string (or array)
+	*	@return string|array
+	*	@param $trace array|NULL
+	*	@param $format bool
+	**/
+	function trace(array $trace=NULL,$format=TRUE) {
+		if (!$trace) {
+			$trace=debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
+			$frame=$trace[0];
+			if (isset($frame['file']) && $frame['file']==__FILE__)
+				array_shift($trace);
+		}
+		$debug=$this->hive['DEBUG'];
+		$trace=array_filter(
+			$trace,
+			function($frame) use($debug) {
+				return isset($frame['file']) &&
+					($debug>1 ||
+					($frame['file']!=__FILE__ || $debug) &&
+					(empty($frame['function']) ||
+					!preg_match('/^(?:(?:trigger|user)_error|'.
+						'__call|call_user_func)/',$frame['function'])));
+			}
+		);
+		if (!$format)
+			return $trace;
+		$out='';
+		$eol="\n";
+		// Analyze stack trace
+		foreach ($trace as $frame) {
+			$line='';
+			if (isset($frame['class']))
+				$line.=$frame['class'].$frame['type'];
+			if (isset($frame['function']))
+				$line.=$frame['function'].'('.
+					($debug>2 && isset($frame['args'])?
+						$this->csv($frame['args']):'').')';
+			$src=$this->fixslashes(str_replace($_SERVER['DOCUMENT_ROOT'].
+				'/','',$frame['file'])).':'.$frame['line'];
+			$out.='['.$src.'] '.$line.$eol;
+		}
+		return $out;
+	}
+
+	/**
+	*	Log error; Execute ONERROR handler if defined, else display
+	*	default error page (HTML for synchronous requests, JSON string
+	*	for AJAX requests)
+	*	@return NULL
+	*	@param $code int
+	*	@param $text string
+	*	@param $trace array
+	*	@param $level int
+	**/
+	function error($code,$text='',array $trace=NULL,$level=0) {
+		$prior=$this->hive['ERROR'];
+		$header=$this->status($code);
+		$req=$this->hive['VERB'].' '.$this->hive['PATH'];
+		if ($this->hive['QUERY'])
+			$req.='?'.$this->hive['QUERY'];
+		if (!$text)
+			$text='HTTP '.$code.' ('.$req.')';
+		$trace=$this->trace($trace);
+		$loggable=$this->hive['LOGGABLE'];
+		if (!is_array($loggable))
+			$loggable=$this->split($loggable);
+		foreach ($loggable as $status)
+			if ($status=='*' || preg_match('/^'.preg_replace('/\D/','\d',$status).'$/',$code)) {
+				error_log($text);
+				foreach (explode("\n",$trace) as $nexus)
+					if ($nexus)
+						error_log($nexus);
+				break;
+			}
+		if ($highlight=!$this->hive['CLI'] && !$this->hive['AJAX'] &&
+			$this->hive['HIGHLIGHT'] && is_file($css=__DIR__.'/'.self::CSS))
+			$trace=$this->highlight($trace);
+		$this->hive['ERROR']=[
+			'status'=>$header,
+			'code'=>$code,
+			'text'=>$text,
+			'trace'=>$trace,
+			'level'=>$level
+		];
+		$this->expire(-1);
+		$handler=$this->hive['ONERROR'];
+		$this->hive['ONERROR']=NULL;
+		$eol="\n";
+		if ((!$handler ||
+			$this->call($handler,[$this,$this->hive['PARAMS']],
+				'beforeroute,afterroute')===FALSE) &&
+			!$prior && !$this->hive['CLI'] && !$this->hive['QUIET'])
+			echo $this->hive['AJAX']?
+				json_encode(array_diff_key($this->hive['ERROR'],$this->hive['DEBUG']?[]:['trace'=>1])):
+				('<!DOCTYPE html>'.$eol.
+				'<html>'.$eol.
+				'<head>'.
+					'<title>'.$code.' '.$header.'</title>'.
+					($highlight?
+						('<style>'.$this->read($css).'</style>'):'').
+				'</head>'.$eol.
+				'<body>'.$eol.
+					'<h1>'.$header.'</h1>'.$eol.
+					'<p>'.$this->encode($text?:$req).'</p>'.$eol.
+					($this->hive['DEBUG']?('<pre>'.$trace.'</pre>'.$eol):'').
+				'</body>'.$eol.
+				'</html>');
+		if ($this->hive['HALT'])
+			die(1);
+	}
+
+	/**
+	*	Mock HTTP request
+	*	@return mixed
+	*	@param $pattern string
+	*	@param $args array
+	*	@param $headers array
+	*	@param $body string
+	**/
+	function mock($pattern,
+		array $args=NULL,array $headers=NULL,$body=NULL) {
+		if (!$args)
+			$args=[];
+		$types=['sync','ajax','cli'];
+		preg_match('/([\|\w]+)\h+(?:@(\w+)(?:(\(.+?)\))*|([^\h]+))'.
+			'(?:\h+\[('.implode('|',$types).')\])?/',$pattern,$parts);
+		$verb=strtoupper($parts[1]);
+		if ($parts[2]) {
+			if (empty($this->hive['ALIASES'][$parts[2]]))
+				user_error(sprintf(self::E_Named,$parts[2]),E_USER_ERROR);
+			$parts[4]=$this->hive['ALIASES'][$parts[2]];
+			$parts[4]=$this->build($parts[4],
+				isset($parts[3])?$this->parse($parts[3]):[]);
+		}
+		if (empty($parts[4]))
+			user_error(sprintf(self::E_Pattern,$pattern),E_USER_ERROR);
+		$url=parse_url($parts[4]);
+		parse_str(@$url['query'],$GLOBALS['_GET']);
+		if (preg_match('/GET|HEAD/',$verb))
+			$GLOBALS['_GET']=array_merge($GLOBALS['_GET'],$args);
+		$GLOBALS['_POST']=$verb=='POST'?$args:[];
+		$GLOBALS['_REQUEST']=array_merge($GLOBALS['_GET'],$GLOBALS['_POST']);
+		foreach ($headers?:[] as $key=>$val)
+			$_SERVER['HTTP_'.strtr(strtoupper($key),'-','_')]=$val;
+		$this->hive['VERB']=$verb;
+		$this->hive['PATH']=$url['path'];
+		$this->hive['URI']=$this->hive['BASE'].$url['path'];
+		if ($GLOBALS['_GET'])
+			$this->hive['URI'].='?'.http_build_query($GLOBALS['_GET']);
+		$this->hive['BODY']='';
+		if (!preg_match('/GET|HEAD/',$verb))
+			$this->hive['BODY']=$body?:http_build_query($args);
+		$this->hive['AJAX']=isset($parts[5]) &&
+			preg_match('/ajax/i',$parts[5]);
+		$this->hive['CLI']=isset($parts[5]) &&
+			preg_match('/cli/i',$parts[5]);
+		return $this->run();
+	}
+
+	/**
+	*	Assemble url from alias name
+	*	@return string
+	*	@param $name string
+	*	@param $params array|string
+	*	@param $query string|array
+	**/
+	function alias($name,$params=[],$query=NULL) {
+		if (!is_array($params))
+			$params=$this->parse($params);
+		if (empty($this->hive['ALIASES'][$name]))
+			user_error(sprintf(self::E_Named,$name),E_USER_ERROR);
+		$url=$this->build($this->hive['ALIASES'][$name],$params);
+		if (is_array($query))
+			$query=http_build_query($query);
+		return $url.($query?('?'.$query):'');
+	}
+
+	/**
+	*	Bind handler to route pattern
+	*	@return NULL
+	*	@param $pattern string|array
+	*	@param $handler callback
+	*	@param $ttl int
+	*	@param $kbps int
+	**/
+	function route($pattern,$handler,$ttl=0,$kbps=0) {
+		$types=['sync','ajax','cli'];
+		$alias=null;
+		if (is_array($pattern)) {
+			foreach ($pattern as $item)
+				$this->route($item,$handler,$ttl,$kbps);
+			return;
+		}
+		preg_match('/([\|\w]+)\h+(?:(?:@?(.+?)\h*:\h*)?(@(\w+)|[^\h]+))'.
+			'(?:\h+\[('.implode('|',$types).')\])?/u',$pattern,$parts);
+		if (isset($parts[2]) && $parts[2])
+			$this->hive['ALIASES'][$alias=$parts[2]]=$parts[3];
+		elseif (!empty($parts[4])) {
+			if (empty($this->hive['ALIASES'][$parts[4]]))
+				user_error(sprintf(self::E_Named,$parts[4]),E_USER_ERROR);
+			$parts[3]=$this->hive['ALIASES'][$alias=$parts[4]];
+		}
+		if (empty($parts[3]))
+			user_error(sprintf(self::E_Pattern,$pattern),E_USER_ERROR);
+		$type=empty($parts[5])?0:constant('self::REQ_'.strtoupper($parts[5]));
+		foreach ($this->split($parts[1]) as $verb) {
+			if (!preg_match('/'.self::VERBS.'/',$verb))
+				$this->error(501,$verb.' '.$this->hive['URI']);
+			$this->hive['ROUTES'][$parts[3]][$type][strtoupper($verb)]=
+				[$handler,$ttl,$kbps,$alias];
+		}
+	}
+
+	/**
+	*	Reroute to specified URI
+	*	@return NULL
+	*	@param $url array|string
+	*	@param $permanent bool
+	*	@param $die bool
+	**/
+	function reroute($url=NULL,$permanent=FALSE,$die=TRUE) {
+		if (!$url)
+			$url=$this->hive['REALM'];
+		if (is_array($url))
+			$url=call_user_func_array([$this,'alias'],$url);
+		elseif (preg_match('/^(?:@?([^\/()?]+)(?:(\(.+?)\))*(\?.+)*)/',
+			$url,$parts) &&
+			isset($this->hive['ALIASES'][$parts[1]]))
+			$url=$this->hive['ALIASES'][$parts[1]];
+		$url=$this->build($url,isset($parts[2])?$this->parse($parts[2]):[]).
+			(isset($parts[3])?$parts[3]:'');
+		if (($handler=$this->hive['ONREROUTE']) &&
+			$this->call($handler,[$url,$permanent])!==FALSE)
+			return;
+		if ($url[0]=='/' && (empty($url[1]) || $url[1]!='/')) {
+			$port=$this->hive['PORT'];
+			$port=in_array($port,[80,443])?'':(':'.$port);
+			$url=$this->hive['SCHEME'].'://'.
+				$this->hive['HOST'].$port.$this->hive['BASE'].$url;
+		}
+		if ($this->hive['CLI'])
+			$this->mock('GET '.$url.' [cli]');
+		else {
+			header('Location: '.$url);
+			$this->status($permanent?301:302);
+			if ($die)
+				die;
+		}
+	}
+
+	/**
+	*	Provide ReST interface by mapping HTTP verb to class method
+	*	@return NULL
+	*	@param $url string
+	*	@param $class string|object
+	*	@param $ttl int
+	*	@param $kbps int
+	**/
+	function map($url,$class,$ttl=0,$kbps=0) {
+		if (is_array($url)) {
+			foreach ($url as $item)
+				$this->map($item,$class,$ttl,$kbps);
+			return;
+		}
+		foreach (explode('|',self::VERBS) as $method)
+			$this->route($method.' '.$url,is_string($class)?
+				$class.'->'.$this->hive['PREMAP'].strtolower($method):
+				[$class,$this->hive['PREMAP'].strtolower($method)],
+				$ttl,$kbps);
+	}
+
+	/**
+	*	Redirect a route to another URL
+	*	@return NULL
+	*	@param $pattern string|array
+	*	@param $url string
+	*	@param $permanent bool
+	*/
+	function redirect($pattern,$url,$permanent=TRUE) {
+		if (is_array($pattern)) {
+			foreach ($pattern as $item)
+				$this->redirect($item,$url,$permanent);
+			return;
+		}
+		$this->route($pattern,function($fw) use($url,$permanent) {
+			$fw->reroute($url,$permanent);
+		});
+	}
+
+	/**
+	*	Return TRUE if IPv4 address exists in DNSBL
+	*	@return bool
+	*	@param $ip string
+	**/
+	function blacklisted($ip) {
+		if ($this->hive['DNSBL'] &&
+			!in_array($ip,
+				is_array($this->hive['EXEMPT'])?
+					$this->hive['EXEMPT']:
+					$this->split($this->hive['EXEMPT']))) {
+			// Reverse IPv4 dotted quad
+			$rev=implode('.',array_reverse(explode('.',$ip)));
+			foreach (is_array($this->hive['DNSBL'])?
+				$this->hive['DNSBL']:
+				$this->split($this->hive['DNSBL']) as $server)
+				// DNSBL lookup
+				if (checkdnsrr($rev.'.'.$server,'A'))
+					return TRUE;
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Applies the specified URL mask and returns parameterized matches
+	*	@return $args array
+	*	@param $pattern string
+	*	@param $url string|NULL
+	**/
+	function mask($pattern,$url=NULL) {
+		if (!$url)
+			$url=$this->rel($this->hive['URI']);
+		$case=$this->hive['CASELESS']?'i':'';
+		$wild=preg_quote($pattern,'/');
+		$i=0;
+		while (is_int($pos=strpos($wild,'\*'))) {
+			$wild=substr_replace($wild,'(?P<_'.$i.'>[^\?]*)',$pos,2);
+			$i++;
+		}
+		preg_match('/^'.
+			preg_replace(
+				'/((\\\{)?@(\w+\b)(?(2)\\\}))/',
+				'(?P<\3>[^\/\?]+)',
+				$wild).'\/?$/'.$case.'um',$url,$args);
+		foreach (array_keys($args) as $key) {
+			if (preg_match('/^_\d+$/',$key)) {
+				if (empty($args['*']))
+					$args['*']=$args[$key];
+				else {
+					if (is_string($args['*']))
+						$args['*']=[$args['*']];
+					array_push($args['*'],$args[$key]);
+				}
+				unset($args[$key]);
+			}
+			elseif (is_numeric($key) && $key)
+				unset($args[$key]);
+		}
+		return $args;
+	}
+
+	/**
+	*	Match routes against incoming URI
+	*	@return mixed
+	**/
+	function run() {
+		if ($this->blacklisted($this->hive['IP']))
+			// Spammer detected
+			$this->error(403);
+		if (!$this->hive['ROUTES'])
+			// No routes defined
+			user_error(self::E_Routes,E_USER_ERROR);
+		// Match specific routes first
+		$paths=[];
+		foreach ($keys=array_keys($this->hive['ROUTES']) as $key) {
+			$path=preg_replace('/@\w+/','*@',$key);
+			if (substr($path,-1)!='*')
+				$path.='+';
+			$paths[]=$path;
+		}
+		$vals=array_values($this->hive['ROUTES']);
+		array_multisort($paths,SORT_DESC,$keys,$vals);
+		$this->hive['ROUTES']=array_combine($keys,$vals);
+		// Convert to BASE-relative URL
+		$req=urldecode($this->hive['PATH']);
+		$preflight=FALSE;
+		if ($cors=(isset($this->hive['HEADERS']['Origin']) &&
+			$this->hive['CORS']['origin'])) {
+			$cors=$this->hive['CORS'];
+			header('Access-Control-Allow-Origin: '.$cors['origin']);
+			header('Access-Control-Allow-Credentials: '.
+				var_export($cors['credentials'],TRUE));
+			$preflight=
+				isset($this->hive['HEADERS']['Access-Control-Request-Method']);
+		}
+		$allowed=[];
+		foreach ($this->hive['ROUTES'] as $pattern=>$routes) {
+			if (!$args=$this->mask($pattern,$req))
+				continue;
+			ksort($args);
+			$route=NULL;
+			$ptr=$this->hive['CLI']?self::REQ_CLI:$this->hive['AJAX']+1;
+			if (isset($routes[$ptr][$this->hive['VERB']]) ||
+				isset($routes[$ptr=0]))
+				$route=$routes[$ptr];
+			if (!$route)
+				continue;
+			if (isset($route[$this->hive['VERB']]) && !$preflight) {
+				if ($this->hive['VERB']=='GET' &&
+					preg_match('/.+\/$/',$this->hive['PATH']))
+					$this->reroute(substr($this->hive['PATH'],0,-1).
+						($this->hive['QUERY']?('?'.$this->hive['QUERY']):''));
+				list($handler,$ttl,$kbps,$alias)=$route[$this->hive['VERB']];
+				// Capture values of route pattern tokens
+				$this->hive['PARAMS']=$args;
+				// Save matching route
+				$this->hive['ALIAS']=$alias;
+				$this->hive['PATTERN']=$pattern;
+				if ($cors && $cors['expose'])
+					header('Access-Control-Expose-Headers: '.
+						(is_array($cors['expose'])?
+							implode(',',$cors['expose']):$cors['expose']));
+				if (is_string($handler)) {
+					// Replace route pattern tokens in handler if any
+					$handler=preg_replace_callback('/({)?@(\w+\b)(?(1)})/',
+						function($id) use($args) {
+							$pid=count($id)>2?2:1;
+							return isset($args[$id[$pid]])?
+								$args[$id[$pid]]:
+								$id[0];
+						},
+						$handler
+					);
+					if (preg_match('/(.+)\h*(?:->|::)/',$handler,$match) &&
+						!class_exists($match[1]))
+						$this->error(404);
+				}
+				// Process request
+				$result=NULL;
+				$body='';
+				$now=microtime(TRUE);
+				if (preg_match('/GET|HEAD/',$this->hive['VERB']) && $ttl) {
+					// Only GET and HEAD requests are cacheable
+					$headers=$this->hive['HEADERS'];
+					$cache=Cache::instance();
+					$cached=$cache->exists(
+						$hash=$this->hash($this->hive['VERB'].' '.
+							$this->hive['URI']).'.url',$data);
+					if ($cached) {
+						if (isset($headers['If-Modified-Since']) &&
+							strtotime($headers['If-Modified-Since'])+
+								$ttl>$now) {
+							$this->status(304);
+							die;
+						}
+						// Retrieve from cache backend
+						list($headers,$body,$result)=$data;
+						if (!$this->hive['CLI'])
+							array_walk($headers,'header');
+						$this->expire($cached[0]+$ttl-$now);
+					}
+					else
+						// Expire HTTP client-cached page
+						$this->expire($ttl);
+				}
+				else
+					$this->expire(0);
+				if (!strlen($body)) {
+					if (!$this->hive['RAW'] && !$this->hive['BODY'])
+						$this->hive['BODY']=file_get_contents('php://input');
+					ob_start();
+					// Call route handler
+					$result=$this->call($handler,[$this,$args,$handler],
+						'beforeroute,afterroute');
+					$body=ob_get_clean();
+					if (isset($cache) && !error_get_last()) {
+						// Save to cache backend
+						$cache->set($hash,[
+							// Remove cookies
+							preg_grep('/Set-Cookie\:/',headers_list(),
+								PREG_GREP_INVERT),$body,$result],$ttl);
+					}
+				}
+				$this->hive['RESPONSE']=$body;
+				if (!$this->hive['QUIET']) {
+					if ($kbps) {
+						$ctr=0;
+						foreach (str_split($body,1024) as $part) {
+							// Throttle output
+							$ctr++;
+							if ($ctr/$kbps>($elapsed=microtime(TRUE)-$now) &&
+								!connection_aborted())
+								usleep(1e6*($ctr/$kbps-$elapsed));
+							echo $part;
+						}
+					}
+					else
+						echo $body;
+				}
+				if ($result || $this->hive['VERB']!='OPTIONS')
+					return $result;
+			}
+			$allowed=array_merge($allowed,array_keys($route));
+		}
+		if (!$allowed)
+			// URL doesn't match any route
+			$this->error(404);
+		elseif (!$this->hive['CLI']) {
+			// Unhandled HTTP method
+			header('Allow: '.implode(',',array_unique($allowed)));
+			if ($cors) {
+				header('Access-Control-Allow-Methods: OPTIONS,'.
+					implode(',',$allowed));
+				if ($cors['headers'])
+					header('Access-Control-Allow-Headers: '.
+						(is_array($cors['headers'])?
+							implode(',',$cors['headers']):
+							$cors['headers']));
+				if ($cors['ttl']>0)
+					header('Access-Control-Max-Age: '.$cors['ttl']);
+			}
+			if ($this->hive['VERB']!='OPTIONS')
+				$this->error(405);
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Loop until callback returns TRUE (for long polling)
+	*	@return mixed
+	*	@param $func callback
+	*	@param $args array
+	*	@param $timeout int
+	**/
+	function until($func,$args=NULL,$timeout=60) {
+		if (!$args)
+			$args=[];
+		$time=time();
+		$max=ini_get('max_execution_time');
+		$limit=max(0,($max?min($timeout,$max):$timeout)-1);
+		$out='';
+		// Turn output buffering on
+		ob_start();
+		// Not for the weak of heart
+		while (
+			// No error occurred
+			!$this->hive['ERROR'] &&
+			// Got time left?
+			time()-$time+1<$limit &&
+			// Still alive?
+			!connection_aborted() &&
+			// Restart session
+			!headers_sent() &&
+			(session_status()==PHP_SESSION_ACTIVE || session_start()) &&
+			// CAUTION: Callback will kill host if it never becomes truthy!
+			!$out=$this->call($func,$args)) {
+			if (!$this->hive['CLI'])
+				session_commit();
+			// Hush down
+			sleep(1);
+		}
+		ob_flush();
+		flush();
+		return $out;
+	}
+
+	/**
+	*	Disconnect HTTP client
+	**/
+	function abort() {
+		if (!headers_sent() && session_status()!=PHP_SESSION_ACTIVE)
+			session_start();
+		$out='';
+		while (ob_get_level())
+			$out=ob_get_clean().$out;
+		header('Content-Encoding: none');
+		header('Content-Length: '.strlen($out));
+		header('Connection: close');
+		session_commit();
+		echo $out;
+		flush();
+		if (function_exists('fastcgi_finish_request'))
+			fastcgi_finish_request();
+	}
+
+	/**
+	*	Grab the real route handler behind the string expression
+	*	@return string|array
+	*	@param $func string
+	*	@param $args array
+	**/
+	function grab($func,$args=NULL) {
+		if (preg_match('/(.+)\h*(->|::)\h*(.+)/s',$func,$parts)) {
+			// Convert string to executable PHP callback
+			if (!class_exists($parts[1]))
+				user_error(sprintf(self::E_Class,$parts[1]),E_USER_ERROR);
+			if ($parts[2]=='->') {
+				if (is_subclass_of($parts[1],'Prefab'))
+					$parts[1]=call_user_func($parts[1].'::instance');
+				elseif ($container=$this->get('CONTAINER')) {
+					if (is_object($container) && is_callable([$container,'has'])
+						&& $container->has($parts[1])) // PSR11
+						$parts[1]=call_user_func([$container,'get'],$parts[1]);
+					elseif (is_callable($container))
+						$parts[1]=call_user_func($container,$parts[1],$args);
+					elseif (is_string($container) && is_subclass_of($container,'Prefab'))
+						$parts[1]=call_user_func($container.'::instance')->get($parts[1]);
+					else
+						user_error(sprintf(self::E_Class,
+							$this->stringify($container)),
+							E_USER_ERROR);
+				}
+				else {
+					$ref=new ReflectionClass($parts[1]);
+					$parts[1]=method_exists($parts[1],'__construct') && $args?
+						$ref->newinstanceargs($args):
+						$ref->newinstance();
+				}
+			}
+			$func=[$parts[1],$parts[3]];
+		}
+		return $func;
+	}
+
+	/**
+	*	Execute callback/hooks (supports 'class->method' format)
+	*	@return mixed|FALSE
+	*	@param $func callback
+	*	@param $args mixed
+	*	@param $hooks string
+	**/
+	function call($func,$args=NULL,$hooks='') {
+		if (!is_array($args))
+			$args=[$args];
+		// Grab the real handler behind the string representation
+		if (is_string($func))
+			$func=$this->grab($func,$args);
+		// Execute function; abort if callback/hook returns FALSE
+		if (!is_callable($func))
+			// No route handler
+			if ($hooks=='beforeroute,afterroute') {
+				$allowed=[];
+				if (is_array($func))
+					$allowed=array_intersect(
+						array_map('strtoupper',get_class_methods($func[0])),
+						explode('|',self::VERBS)
+					);
+				header('Allow: '.implode(',',$allowed));
+				$this->error(405);
+			}
+			else
+				user_error(sprintf(self::E_Method,
+					is_string($func)?$func:$this->stringify($func)),
+					E_USER_ERROR);
+		$obj=FALSE;
+		if (is_array($func)) {
+			$hooks=$this->split($hooks);
+			$obj=TRUE;
+		}
+		// Execute pre-route hook if any
+		if ($obj && $hooks && in_array($hook='beforeroute',$hooks) &&
+			method_exists($func[0],$hook) &&
+			call_user_func_array([$func[0],$hook],$args)===FALSE)
+			return FALSE;
+		// Execute callback
+		$out=call_user_func_array($func,$args?:[]);
+		if ($out===FALSE)
+			return FALSE;
+		// Execute post-route hook if any
+		if ($obj && $hooks && in_array($hook='afterroute',$hooks) &&
+			method_exists($func[0],$hook) &&
+			call_user_func_array([$func[0],$hook],$args)===FALSE)
+			return FALSE;
+		return $out;
+	}
+
+	/**
+	*	Execute specified callbacks in succession; Apply same arguments
+	*	to all callbacks
+	*	@return array
+	*	@param $funcs array|string
+	*	@param $args mixed
+	**/
+	function chain($funcs,$args=NULL) {
+		$out=[];
+		foreach (is_array($funcs)?$funcs:$this->split($funcs) as $func)
+			$out[]=$this->call($func,$args);
+		return $out;
+	}
+
+	/**
+	*	Execute specified callbacks in succession; Relay result of
+	*	previous callback as argument to the next callback
+	*	@return array
+	*	@param $funcs array|string
+	*	@param $args mixed
+	**/
+	function relay($funcs,$args=NULL) {
+		foreach (is_array($funcs)?$funcs:$this->split($funcs) as $func)
+			$args=[$this->call($func,$args)];
+		return array_shift($args);
+	}
+
+	/**
+	*	Configure framework according to .ini-style file settings;
+	*	If optional 2nd arg is provided, template strings are interpreted
+	*	@return object
+	*	@param $source string|array
+	*	@param $allow bool
+	**/
+	function config($source,$allow=FALSE) {
+		if (is_string($source))
+			$source=$this->split($source);
+		if ($allow)
+			$preview=Preview::instance();
+		foreach ($source as $file) {
+			preg_match_all(
+				'/(?<=^|\n)(?:'.
+					'\[(?<section>.+?)\]|'.
+					'(?<lval>[^\h\r\n;].*?)\h*=\h*'.
+					'(?<rval>(?:\\\\\h*\r?\n|.+?)*)'.
+				')(?=\r?\n|$)/',
+				$this->read($file),
+				$matches,PREG_SET_ORDER);
+			if ($matches) {
+				$sec='globals';
+				$cmd=[];
+				foreach ($matches as $match) {
+					if ($match['section']) {
+						$sec=$match['section'];
+						if (preg_match(
+							'/^(?!(?:global|config|route|map|redirect)s\b)'.
+							'((?:\.?\w)+)/i',$sec,$msec) &&
+							!$this->exists($msec[0]))
+							$this->set($msec[0],NULL);
+						preg_match('/^(config|route|map|redirect)s\b|'.
+							'^((?:\.?\w)+)\s*\>\s*(.*)/i',$sec,$cmd);
+						continue;
+					}
+					if ($allow)
+						foreach (['lval','rval'] as $ndx)
+							$match[$ndx]=$preview->
+								resolve($match[$ndx],NULL,0,FALSE,FALSE);
+					if (!empty($cmd)) {
+						isset($cmd[3])?
+						$this->call($cmd[3],
+							[$match['lval'],$match['rval'],$cmd[2]]):
+						call_user_func_array(
+							[$this,$cmd[1]],
+							array_merge([$match['lval']],
+								str_getcsv($cmd[1]=='config'?$this->cast($match['rval']):
+									$match['rval']))
+						);
+					}
+					else {
+						$rval=preg_replace(
+							'/\\\\\h*(\r?\n)/','\1',$match['rval']);
+						$ttl=NULL;
+						if (preg_match('/^(.+)\|\h*(\d+)$/',$rval,$tmp)) {
+							array_shift($tmp);
+							list($rval,$ttl)=$tmp;
+						}
+						$args=array_map(
+							function($val) {
+								$val=$this->cast($val);
+								return is_string($val)
+									? preg_replace('/\\\\"/','"',$val)
+									: $val;
+							},
+							// Mark quoted strings with 0x00 whitespace
+							str_getcsv(preg_replace(
+								'/(?<!\\\\)(")(.*?)\1/',
+								"\\1\x00\\2\\1",trim($rval)))
+						);
+						preg_match('/^(?<section>[^:]+)(?:\:(?<func>.+))?/',
+							$sec,$parts);
+						$func=isset($parts['func'])?$parts['func']:NULL;
+						$custom=(strtolower($parts['section'])!='globals');
+						if ($func)
+							$args=[$this->call($func,$args)];
+						if (count($args)>1)
+							$args=[$args];
+						if (isset($ttl))
+							$args=array_merge($args,[$ttl]);
+						call_user_func_array(
+							[$this,'set'],
+							array_merge(
+								[
+									($custom?($parts['section'].'.'):'').
+									$match['lval']
+								],
+								$args
+							)
+						);
+					}
+				}
+			}
+		}
+		return $this;
+	}
+
+	/**
+	*	Create mutex, invoke callback then drop ownership when done
+	*	@return mixed
+	*	@param $id string
+	*	@param $func callback
+	*	@param $args mixed
+	**/
+	function mutex($id,$func,$args=NULL) {
+		if (!is_dir($tmp=$this->hive['TEMP']))
+			mkdir($tmp,self::MODE,TRUE);
+		// Use filesystem lock
+		if (is_file($lock=$tmp.
+			$this->get('SEED').'.'.$this->hash($id).'.lock') &&
+			filemtime($lock)+ini_get('max_execution_time')<microtime(TRUE))
+			// Stale lock
+			@unlink($lock);
+		while (!($handle=@fopen($lock,'x')) && !connection_aborted())
+			usleep(mt_rand(0,100));
+		$this->locks[$id]=$lock;
+		$out=$this->call($func,$args);
+		fclose($handle);
+		@unlink($lock);
+		unset($this->locks[$id]);
+		return $out;
+	}
+
+	/**
+	*	Read file (with option to apply Unix LF as standard line ending)
+	*	@return string
+	*	@param $file string
+	*	@param $lf bool
+	**/
+	function read($file,$lf=FALSE) {
+		$out=@file_get_contents($file);
+		return $lf?preg_replace('/\r\n|\r/',"\n",$out):$out;
+	}
+
+	/**
+	*	Exclusive file write
+	*	@return int|FALSE
+	*	@param $file string
+	*	@param $data mixed
+	*	@param $append bool
+	**/
+	function write($file,$data,$append=FALSE) {
+		return file_put_contents($file,$data,LOCK_EX|($append?FILE_APPEND:0));
+	}
+
+	/**
+	*	Apply syntax highlighting
+	*	@return string
+	*	@param $text string
+	**/
+	function highlight($text) {
+		$out='';
+		$pre=FALSE;
+		$text=trim($text);
+		if ($text && !preg_match('/^<\?php/',$text)) {
+			$text='<?php '.$text;
+			$pre=TRUE;
+		}
+		foreach (token_get_all($text) as $token)
+			if ($pre)
+				$pre=FALSE;
+			else
+				$out.='<span'.
+					(is_array($token)?
+						(' class="'.
+							substr(strtolower(token_name($token[0])),2).'">'.
+							$this->encode($token[1]).''):
+						('>'.$this->encode($token))).
+					'</span>';
+		return $out?('<code>'.$out.'</code>'):$text;
+	}
+
+	/**
+	*	Dump expression with syntax highlighting
+	*	@return NULL
+	*	@param $expr mixed
+	**/
+	function dump($expr) {
+		echo $this->highlight($this->stringify($expr));
+	}
+
+	/**
+	*	Return path (and query parameters) relative to the base directory
+	*	@return string
+	*	@param $url string
+	**/
+	function rel($url) {
+		return preg_replace('/^(?:https?:\/\/)?'.
+			preg_quote($this->hive['BASE'],'/').'(\/.*|$)/','\1',$url);
+	}
+
+	/**
+	*	Namespace-aware class autoloader
+	*	@return mixed
+	*	@param $class string
+	**/
+	protected function autoload($class) {
+		$class=$this->fixslashes(ltrim($class,'\\'));
+		$func=NULL;
+		if (is_array($path=$this->hive['AUTOLOAD']) &&
+			isset($path[1]) && is_callable($path[1]))
+			list($path,$func)=$path;
+		foreach ($this->split($this->hive['PLUGINS'].';'.$path) as $auto)
+			if ($func && is_file($file=$func($auto.$class).'.php') ||
+				is_file($file=$auto.$class.'.php') ||
+				is_file($file=$auto.strtolower($class).'.php') ||
+				is_file($file=strtolower($auto.$class).'.php'))
+				return require($file);
+	}
+
+	/**
+	*	Execute framework/application shutdown sequence
+	*	@param $cwd string
+	**/
+	function unload($cwd) {
+		chdir($cwd);
+		if (!($error=error_get_last()) &&
+			session_status()==PHP_SESSION_ACTIVE)
+			session_commit();
+		foreach ($this->locks as $lock)
+			@unlink($lock);
+		$handler=$this->hive['UNLOAD'];
+		if ((!$handler || $this->call($handler,$this)===FALSE) &&
+			$error && in_array($error['type'],
+			[E_ERROR,E_PARSE,E_CORE_ERROR,E_COMPILE_ERROR]))
+			// Fatal error detected
+			$this->error(500,
+				sprintf(self::E_Fatal,$error['message']),[$error]);
+	}
+
+	/**
+	*	Convenience method for checking hive key
+	*	@return mixed
+	*	@param $key string
+	**/
+	function offsetexists($key) {
+		return $this->exists($key);
+	}
+
+	/**
+	*	Convenience method for assigning hive value
+	*	@return mixed
+	*	@param $key string
+	*	@param $val scalar
+	**/
+	function offsetset($key,$val) {
+		return $this->set($key,$val);
+	}
+
+	/**
+	*	Convenience method for retrieving hive value
+	*	@return mixed
+	*	@param $key string
+	**/
+	function &offsetget($key) {
+		$val=&$this->ref($key);
+		return $val;
+	}
+
+	/**
+	*	Convenience method for removing hive key
+	*	@return NULL
+	*	@param $key string
+	**/
+	function offsetunset($key) {
+		$this->clear($key);
+	}
+
+	/**
+	*	Alias for offsetexists()
+	*	@return mixed
+	*	@param $key string
+	**/
+	function __isset($key) {
+		return $this->offsetexists($key);
+	}
+
+	/**
+	*	Alias for offsetset()
+	*	@return mixed
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	function __set($key,$val) {
+		return $this->offsetset($key,$val);
+	}
+
+	/**
+	*	Alias for offsetget()
+	*	@return mixed
+	*	@param $key string
+	**/
+	function &__get($key) {
+		$val=&$this->offsetget($key);
+		return $val;
+	}
+
+	/**
+	*	Alias for offsetunset()
+	*	@return mixed
+	*	@param $key string
+	**/
+	function __unset($key) {
+		$this->offsetunset($key);
+	}
+
+	/**
+	*	Call function identified by hive key
+	*	@return mixed
+	*	@param $key string
+	*	@param $args array
+	**/
+	function __call($key,array $args) {
+		if ($this->exists($key,$val))
+			return call_user_func_array($val,$args);
+		user_error(sprintf(self::E_Method,$key),E_USER_ERROR);
+	}
+
+	//! Prohibit cloning
+	private function __clone() {
+	}
+
+	//! Bootstrap
+	function __construct() {
+		// Managed directives
+		ini_set('default_charset',$charset='UTF-8');
+		if (extension_loaded('mbstring'))
+			mb_internal_encoding($charset);
+		ini_set('display_errors',0);
+		// Deprecated directives
+		@ini_set('magic_quotes_gpc',0);
+		@ini_set('register_globals',0);
+		// Intercept errors/exceptions; PHP5.3-compatible
+		$check=error_reporting((E_ALL|E_STRICT)&~(E_NOTICE|E_USER_NOTICE));
+		set_exception_handler(
+			function($obj) {
+				$this->hive['EXCEPTION']=$obj;
+				$this->error(500,
+					$obj->getmessage().' '.
+					'['.$obj->getFile().':'.$obj->getLine().']',
+					$obj->gettrace());
+			}
+		);
+		set_error_handler(
+			function($level,$text,$file,$line) {
+				if ($level & error_reporting())
+					$this->error(500,$text,NULL,$level);
+			}
+		);
+		if (!isset($_SERVER['SERVER_NAME']) || $_SERVER['SERVER_NAME']==='')
+			$_SERVER['SERVER_NAME']=gethostname();
+		if ($cli=PHP_SAPI=='cli') {
+			// Emulate HTTP request
+			$_SERVER['REQUEST_METHOD']='GET';
+			if (!isset($_SERVER['argv'][1])) {
+				$_SERVER['argc']++;
+				$_SERVER['argv'][1]='/';
+			}
+			$req=$query='';
+			if (substr($_SERVER['argv'][1],0,1)=='/') {
+				$req=$_SERVER['argv'][1];
+				$query=parse_url($req,PHP_URL_QUERY);
+			} else {
+				foreach($_SERVER['argv'] as $i=>$arg) {
+					if (!$i) continue;
+					if (preg_match('/^\-(\-)?(\w+)(?:\=(.*))?$/',$arg,$m)) {
+						foreach($m[1]?[$m[2]]:str_split($m[2]) as $k)
+							$query.=($query?'&':'').urlencode($k).'=';
+						if (isset($m[3]))
+							$query.=urlencode($m[3]);
+					} else
+						$req.='/'.$arg;
+				}
+				if (!$req)
+					$req='/';
+				if ($query)
+					$req.='?'.$query;
+			}
+			$_SERVER['REQUEST_URI']=$req;
+			parse_str($query,$GLOBALS['_GET']);
+		}
+		$headers=[];
+		if (!$cli) {
+			if (function_exists('getallheaders')) {
+				foreach (getallheaders() as $key=>$val) {
+					$tmp=strtoupper(strtr($key,'-','_'));
+					// TODO: use ucwords delimiters for php 5.4.32+ & 5.5.16+
+					$key=strtr(ucwords(strtolower(strtr($key,'-',' '))),' ','-');
+					$headers[$key]=$val;
+					if (isset($_SERVER['HTTP_'.$tmp]))
+						$headers[$key]=&$_SERVER['HTTP_'.$tmp];
+				}
+			}
+			else {
+				if (isset($_SERVER['CONTENT_LENGTH']))
+					$headers['Content-Length']=&$_SERVER['CONTENT_LENGTH'];
+				if (isset($_SERVER['CONTENT_TYPE']))
+					$headers['Content-Type']=&$_SERVER['CONTENT_TYPE'];
+				foreach (array_keys($_SERVER) as $key)
+					if (substr($key,0,5)=='HTTP_')
+						$headers[strtr(ucwords(strtolower(strtr(
+							substr($key,5),'_',' '))),' ','-')]=&$_SERVER[$key];
+			}
+		}
+		if (isset($headers['X-HTTP-Method-Override']))
+			$_SERVER['REQUEST_METHOD']=$headers['X-HTTP-Method-Override'];
+		elseif ($_SERVER['REQUEST_METHOD']=='POST' && isset($_POST['_method']))
+			$_SERVER['REQUEST_METHOD']=$_POST['_method'];
+		$scheme=isset($_SERVER['HTTPS']) && $_SERVER['HTTPS']=='on' ||
+			isset($headers['X-Forwarded-Proto']) &&
+			$headers['X-Forwarded-Proto']=='https'?'https':'http';
+		// Create hive early on to expose header methods
+		$this->hive=['HEADERS'=>&$headers];
+		if (function_exists('apache_setenv')) {
+			// Work around Apache pre-2.4 VirtualDocumentRoot bug
+			$_SERVER['DOCUMENT_ROOT']=str_replace($_SERVER['SCRIPT_NAME'],'',
+				$_SERVER['SCRIPT_FILENAME']);
+			apache_setenv("DOCUMENT_ROOT",$_SERVER['DOCUMENT_ROOT']);
+		}
+		$_SERVER['DOCUMENT_ROOT']=realpath($_SERVER['DOCUMENT_ROOT']);
+		$base='';
+		if (!$cli)
+			$base=rtrim($this->fixslashes(
+				dirname($_SERVER['SCRIPT_NAME'])),'/');
+		$uri=parse_url((preg_match('/^\w+:\/\//',$_SERVER['REQUEST_URI'])?'':
+				$scheme.'://'.$_SERVER['SERVER_NAME']).$_SERVER['REQUEST_URI']);
+		$_SERVER['REQUEST_URI']=$uri['path'].
+			(isset($uri['query'])?'?'.$uri['query']:'').
+			(isset($uri['fragment'])?'#'.$uri['fragment']:'');
+		$path=preg_replace('/^'.preg_quote($base,'/').'/','',$uri['path']);
+		$jar=[
+			'expire'=>0,
+			'lifetime'=>0,
+			'path'=>$base?:'/',
+			'domain'=>is_int(strpos($_SERVER['SERVER_NAME'],'.')) &&
+				!filter_var($_SERVER['SERVER_NAME'],FILTER_VALIDATE_IP)?
+				$_SERVER['SERVER_NAME']:'',
+			'secure'=>($scheme=='https'),
+			'httponly'=>TRUE
+		];
+		$port=80;
+		if (isset($headers['X-Forwarded-Port']))
+			$port=$headers['X-Forwarded-Port'];
+		elseif (isset($_SERVER['SERVER_PORT']))
+			$port=$_SERVER['SERVER_PORT'];
+		// Default configuration
+		$this->hive+=[
+			'AGENT'=>$this->agent(),
+			'AJAX'=>$this->ajax(),
+			'ALIAS'=>NULL,
+			'ALIASES'=>[],
+			'AUTOLOAD'=>'./',
+			'BASE'=>$base,
+			'BITMASK'=>ENT_COMPAT,
+			'BODY'=>NULL,
+			'CACHE'=>FALSE,
+			'CASELESS'=>TRUE,
+			'CLI'=>$cli,
+			'CORS'=>[
+				'headers'=>'',
+				'origin'=>FALSE,
+				'credentials'=>FALSE,
+				'expose'=>FALSE,
+				'ttl'=>0
+			],
+			'DEBUG'=>0,
+			'DIACRITICS'=>[],
+			'DNSBL'=>'',
+			'EMOJI'=>[],
+			'ENCODING'=>$charset,
+			'ERROR'=>NULL,
+			'ESCAPE'=>TRUE,
+			'EXCEPTION'=>NULL,
+			'EXEMPT'=>NULL,
+			'FALLBACK'=>$this->fallback,
+			'FORMATS'=>[],
+			'FRAGMENT'=>isset($uri['fragment'])?$uri['fragment']:'',
+			'HALT'=>TRUE,
+			'HIGHLIGHT'=>FALSE,
+			'HOST'=>$_SERVER['SERVER_NAME'],
+			'IP'=>$this->ip(),
+			'JAR'=>$jar,
+			'LANGUAGE'=>isset($headers['Accept-Language'])?
+				$this->language($headers['Accept-Language']):
+				$this->fallback,
+			'LOCALES'=>'./',
+			'LOGGABLE'=>'*',
+			'LOGS'=>'./',
+			'MB'=>extension_loaded('mbstring'),
+			'ONERROR'=>NULL,
+			'ONREROUTE'=>NULL,
+			'PACKAGE'=>self::PACKAGE,
+			'PARAMS'=>[],
+			'PATH'=>$path,
+			'PATTERN'=>NULL,
+			'PLUGINS'=>$this->fixslashes(__DIR__).'/',
+			'PORT'=>$port,
+			'PREFIX'=>NULL,
+			'PREMAP'=>'',
+			'QUERY'=>isset($uri['query'])?$uri['query']:'',
+			'QUIET'=>FALSE,
+			'RAW'=>FALSE,
+			'REALM'=>$scheme.'://'.$_SERVER['SERVER_NAME'].
+				($port && !in_array($port,[80,443])?(':'.$port):'').
+				$_SERVER['REQUEST_URI'],
+			'RESPONSE'=>'',
+			'ROOT'=>$_SERVER['DOCUMENT_ROOT'],
+			'ROUTES'=>[],
+			'SCHEME'=>$scheme,
+			'SEED'=>$this->hash($_SERVER['SERVER_NAME'].$base),
+			'SERIALIZER'=>extension_loaded($ext='igbinary')?$ext:'php',
+			'TEMP'=>'tmp/',
+			'TIME'=>&$_SERVER['REQUEST_TIME_FLOAT'],
+			'TZ'=>@date_default_timezone_get(),
+			'UI'=>'./',
+			'UNLOAD'=>NULL,
+			'UPLOADS'=>'./',
+			'URI'=>&$_SERVER['REQUEST_URI'],
+			'VERB'=>&$_SERVER['REQUEST_METHOD'],
+			'VERSION'=>self::VERSION,
+			'XFRAME'=>'SAMEORIGIN'
+		];
+		if (!headers_sent() && session_status()!=PHP_SESSION_ACTIVE) {
+			unset($jar['expire']);
+			session_cache_limiter('');
+			call_user_func_array('session_set_cookie_params',$jar);
+		}
+		if (PHP_SAPI=='cli-server' &&
+			preg_match('/^'.preg_quote($base,'/').'$/',$this->hive['URI']))
+			$this->reroute('/');
+		if (ini_get('auto_globals_jit'))
+			// Override setting
+			$GLOBALS+=['_ENV'=>$_ENV,'_REQUEST'=>$_REQUEST];
+		// Sync PHP globals with corresponding hive keys
+		$this->init=$this->hive;
+		foreach (explode('|',self::GLOBALS) as $global) {
+			$sync=$this->sync($global);
+			$this->init+=[
+				$global=>preg_match('/SERVER|ENV/',$global)?$sync:[]
+			];
+		}
+		if ($check && $error=error_get_last())
+			// Error detected
+			$this->error(500,
+				sprintf(self::E_Fatal,$error['message']),[$error]);
+		date_default_timezone_set($this->hive['TZ']);
+		// Register framework autoloader
+		spl_autoload_register([$this,'autoload']);
+		// Register shutdown handler
+		register_shutdown_function([$this,'unload'],getcwd());
+	}
+
+}
+
+//! Cache engine
+class Cache extends Prefab {
+
+	protected
+		//! Cache DSN
+		$dsn,
+		//! Prefix for cache entries
+		$prefix,
+		//! MemCache or Redis object
+		$ref;
+
+	/**
+	*	Return timestamp and TTL of cache entry or FALSE if not found
+	*	@return array|FALSE
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	function exists($key,&$val=NULL) {
+		$fw=Base::instance();
+		if (!$this->dsn)
+			return FALSE;
+		$ndx=$this->prefix.'.'.$key;
+		$parts=explode('=',$this->dsn,2);
+		switch ($parts[0]) {
+			case 'apc':
+			case 'apcu':
+				$raw=call_user_func($parts[0].'_fetch',$ndx);
+				break;
+			case 'redis':
+				$raw=$this->ref->get($ndx);
+				break;
+			case 'memcache':
+				$raw=memcache_get($this->ref,$ndx);
+				break;
+			case 'memcached':
+				$raw=$this->ref->get($ndx);
+				break;
+			case 'wincache':
+				$raw=wincache_ucache_get($ndx);
+				break;
+			case 'xcache':
+				$raw=xcache_get($ndx);
+				break;
+			case 'folder':
+				$raw=$fw->read($parts[1].$ndx);
+				break;
+		}
+		if (!empty($raw)) {
+			list($val,$time,$ttl)=(array)$fw->unserialize($raw);
+			if ($ttl===0 || $time+$ttl>microtime(TRUE))
+				return [$time,$ttl];
+			$val=null;
+			$this->clear($key);
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Store value in cache
+	*	@return mixed|FALSE
+	*	@param $key string
+	*	@param $val mixed
+	*	@param $ttl int
+	**/
+	function set($key,$val,$ttl=0) {
+		$fw=Base::instance();
+		if (!$this->dsn)
+			return TRUE;
+		$ndx=$this->prefix.'.'.$key;
+		$time=microtime(TRUE);
+		if ($cached=$this->exists($key))
+			list($time,$ttl)=$cached;
+		$data=$fw->serialize([$val,$time,$ttl]);
+		$parts=explode('=',$this->dsn,2);
+		switch ($parts[0]) {
+			case 'apc':
+			case 'apcu':
+				return call_user_func($parts[0].'_store',$ndx,$data,$ttl);
+			case 'redis':
+				return $this->ref->set($ndx,$data,$ttl?['ex'=>$ttl]:[]);
+			case 'memcache':
+				return memcache_set($this->ref,$ndx,$data,0,$ttl);
+			case 'memcached':
+				return $this->ref->set($ndx,$data,$ttl);
+			case 'wincache':
+				return wincache_ucache_set($ndx,$data,$ttl);
+			case 'xcache':
+				return xcache_set($ndx,$data,$ttl);
+			case 'folder':
+				return $fw->write($parts[1].str_replace(['/','\\'],'',$ndx),$data);
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Retrieve value of cache entry
+	*	@return mixed|FALSE
+	*	@param $key string
+	**/
+	function get($key) {
+		return $this->dsn && $this->exists($key,$data)?$data:FALSE;
+	}
+
+	/**
+	*	Delete cache entry
+	*	@return bool
+	*	@param $key string
+	**/
+	function clear($key) {
+		if (!$this->dsn)
+			return;
+		$ndx=$this->prefix.'.'.$key;
+		$parts=explode('=',$this->dsn,2);
+		switch ($parts[0]) {
+			case 'apc':
+			case 'apcu':
+				return call_user_func($parts[0].'_delete',$ndx);
+			case 'redis':
+				return $this->ref->del($ndx);
+			case 'memcache':
+				return memcache_delete($this->ref,$ndx);
+			case 'memcached':
+				return $this->ref->delete($ndx);
+			case 'wincache':
+				return wincache_ucache_delete($ndx);
+			case 'xcache':
+				return xcache_unset($ndx);
+			case 'folder':
+				return @unlink($parts[1].$ndx);
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Clear contents of cache backend
+	*	@return bool
+	*	@param $suffix string
+	**/
+	function reset($suffix=NULL) {
+		if (!$this->dsn)
+			return TRUE;
+		$regex='/'.preg_quote($this->prefix.'.','/').'.*'.
+			preg_quote($suffix,'/').'/';
+		$parts=explode('=',$this->dsn,2);
+		switch ($parts[0]) {
+			case 'apc':
+			case 'apcu':
+				$info=call_user_func($parts[0].'_cache_info',
+					$parts[0]=='apcu'?FALSE:'user');
+				if (!empty($info['cache_list'])) {
+					$key=array_key_exists('info',
+						$info['cache_list'][0])?'info':'key';
+					foreach ($info['cache_list'] as $item)
+						if (preg_match($regex,$item[$key]))
+							call_user_func($parts[0].'_delete',$item[$key]);
+				}
+				return TRUE;
+			case 'redis':
+				$keys=$this->ref->keys($this->prefix.'.*'.$suffix);
+				foreach($keys as $key)
+					$this->ref->del($key);
+				return TRUE;
+			case 'memcache':
+				foreach (memcache_get_extended_stats(
+					$this->ref,'slabs') as $slabs)
+					foreach (array_filter(array_keys($slabs),'is_numeric')
+						as $id)
+						foreach (memcache_get_extended_stats(
+							$this->ref,'cachedump',$id) as $data)
+							if (is_array($data))
+								foreach (array_keys($data) as $key)
+									if (preg_match($regex,$key))
+										memcache_delete($this->ref,$key);
+				return TRUE;
+			case 'memcached':
+				foreach ($this->ref->getallkeys()?:[] as $key)
+					if (preg_match($regex,$key))
+						$this->ref->delete($key);
+				return TRUE;
+			case 'wincache':
+				$info=wincache_ucache_info();
+				foreach ($info['ucache_entries'] as $item)
+					if (preg_match($regex,$item['key_name']))
+						wincache_ucache_delete($item['key_name']);
+				return TRUE;
+			case 'xcache':
+				if ($suffix && !ini_get('xcache.admin.enable_auth')) {
+					$cnt=xcache_count(XC_TYPE_VAR);
+					for ($i=0;$i<$cnt;$i++) {
+						$list=xcache_list(XC_TYPE_VAR,$i);
+						foreach ($list['cache_list'] as $item)
+							if (preg_match($regex,$item['name']))
+								xcache_unset($item['name']);
+					}
+				} else
+					xcache_unset_by_prefix($this->prefix.'.');
+				return TRUE;
+			case 'folder':
+				if ($glob=@glob($parts[1].'*'))
+					foreach ($glob as $file)
+						if (preg_match($regex,basename($file)))
+							@unlink($file);
+				return TRUE;
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Load/auto-detect cache backend
+	*	@return string
+	*	@param $dsn bool|string
+	*	@param $seed bool|string
+	**/
+	function load($dsn,$seed=NULL) {
+		$fw=Base::instance();
+		if ($dsn=trim($dsn)) {
+			if (preg_match('/^redis=(.+)/',$dsn,$parts) &&
+				extension_loaded('redis')) {
+				list($host,$port,$db)=explode(':',$parts[1])+[1=>6379,2=>NULL];
+				$this->ref=new Redis;
+				if(!$this->ref->connect($host,$port,2))
+					$this->ref=NULL;
+				if(isset($db))
+					$this->ref->select($db);
+			}
+			elseif (preg_match('/^memcache=(.+)/',$dsn,$parts) &&
+				extension_loaded('memcache'))
+				foreach ($fw->split($parts[1]) as $server) {
+					list($host,$port)=explode(':',$server)+[1=>11211];
+					if (empty($this->ref))
+						$this->ref=@memcache_connect($host,$port)?:NULL;
+					else
+						memcache_add_server($this->ref,$host,$port);
+				}
+			elseif (preg_match('/^memcached=(.+)/',$dsn,$parts) &&
+				extension_loaded('memcached'))
+				foreach ($fw->split($parts[1]) as $server) {
+					list($host,$port)=explode(':',$server)+[1=>11211];
+					if (empty($this->ref))
+						$this->ref=new Memcached();
+					$this->ref->addServer($host,$port);
+				}
+			if (empty($this->ref) && !preg_match('/^folder\h*=/',$dsn))
+				$dsn=($grep=preg_grep('/^(apc|wincache|xcache)/',
+					array_map('strtolower',get_loaded_extensions())))?
+						// Auto-detect
+						current($grep):
+						// Use filesystem as fallback
+						('folder='.$fw->TEMP.'cache/');
+			if (preg_match('/^folder\h*=\h*(.+)/',$dsn,$parts) &&
+				!is_dir($parts[1]))
+				mkdir($parts[1],Base::MODE,TRUE);
+		}
+		$this->prefix=$seed?:$fw->SEED;
+		return $this->dsn=$dsn;
+	}
+
+	/**
+	*	Class constructor
+	*	@param $dsn bool|string
+	**/
+	function __construct($dsn=FALSE) {
+		if ($dsn)
+			$this->load($dsn);
+	}
+
+}
+
+//! View handler
+class View extends Prefab {
+
+	private
+		//! Temporary hive
+		$temp;
+
+	protected
+		//! Template file
+		$file,
+		//! Post-rendering handler
+		$trigger,
+		//! Nesting level
+		$level=0;
+
+	/** @var \Base Framework instance */
+	protected $fw;
+
+	function __construct() {
+		$this->fw=\Base::instance();
+	}
+
+	/**
+	*	Encode characters to equivalent HTML entities
+	*	@return string
+	*	@param $arg mixed
+	**/
+	function esc($arg) {
+		return $this->fw->recursive($arg,
+			function($val) {
+				return is_string($val)?$this->fw->encode($val):$val;
+			}
+		);
+	}
+
+	/**
+	*	Decode HTML entities to equivalent characters
+	*	@return string
+	*	@param $arg mixed
+	**/
+	function raw($arg) {
+		return $this->fw->recursive($arg,
+			function($val) {
+				return is_string($val)?$this->fw->decode($val):$val;
+			}
+		);
+	}
+
+	/**
+	*	Create sandbox for template execution
+	*	@return string
+	*	@param $hive array
+	*	@param $mime string
+	**/
+	protected function sandbox(array $hive=NULL,$mime=NULL) {
+		$fw=$this->fw;
+		$implicit=FALSE;
+		if (is_null($hive)) {
+			$implicit=TRUE;
+			$hive=$fw->hive();
+		}
+		if ($this->level<1 || $implicit) {
+			if (!$fw->CLI && $mime && !headers_sent() &&
+				!preg_grep ('/^Content-Type:/',headers_list()))
+				header('Content-Type: '.$mime.'; '.
+					'charset='.$fw->ENCODING);
+			if ($fw->ESCAPE)
+				$hive=$this->esc($hive);
+			if (isset($hive['ALIASES']))
+				$hive['ALIASES']=$fw->build($hive['ALIASES']);
+		}
+		$this->temp=$hive;
+		unset($fw,$hive,$implicit,$mime);
+		extract($this->temp);
+		$this->temp=NULL;
+		$this->level++;
+		ob_start();
+		require($this->file);
+		$this->level--;
+		return ob_get_clean();
+	}
+
+	/**
+	*	Render template
+	*	@return string
+	*	@param $file string
+	*	@param $mime string
+	*	@param $hive array
+	*	@param $ttl int
+	**/
+	function render($file,$mime='text/html',array $hive=NULL,$ttl=0) {
+		$fw=$this->fw;
+		$cache=Cache::instance();
+		foreach ($fw->split($fw->UI) as $dir) {
+			if ($cache->exists($hash=$fw->hash($dir.$file),$data))
+				return $data;
+			if (is_file($this->file=$fw->fixslashes($dir.$file))) {
+				if (isset($_COOKIE[session_name()]) &&
+					!headers_sent() && session_status()!=PHP_SESSION_ACTIVE)
+					session_start();
+				$fw->sync('SESSION');
+				$data=$this->sandbox($hive,$mime);
+				if (isset($this->trigger['afterrender']))
+					foreach($this->trigger['afterrender'] as $func)
+						$data=$fw->call($func,[$data, $dir.$file]);
+				if ($ttl)
+					$cache->set($hash,$data,$ttl);
+				return $data;
+			}
+		}
+		user_error(sprintf(Base::E_Open,$file),E_USER_ERROR);
+	}
+
+	/**
+	*	post rendering handler
+	*	@param $func callback
+	*/
+	function afterrender($func) {
+		$this->trigger['afterrender'][]=$func;
+	}
+
+}
+
+//! Lightweight template engine
+class Preview extends View {
+
+	protected
+		//! token filter
+		$filter=[
+			'c'=>'$this->c',
+			'esc'=>'$this->esc',
+			'raw'=>'$this->raw',
+			'alias'=>'Base::instance()->alias',
+			'format'=>'Base::instance()->format'
+		];
+
+	protected
+		//! newline interpolation
+		$interpolation=true;
+
+	/**
+	 * enable/disable markup parsing interpolation
+	 * mainly used for adding appropriate newlines
+	 * @param $bool bool
+	 */
+	function interpolation($bool) {
+		$this->interpolation=$bool;
+	}
+
+	/**
+	*	Return C-locale equivalent of number
+	*	@return string
+	*	@param $val int|float
+	**/
+	function c($val) {
+		$locale=setlocale(LC_NUMERIC,0);
+		setlocale(LC_NUMERIC,'C');
+		$out=(string)(float)$val;
+		$locale=setlocale(LC_NUMERIC,$locale);
+		return $out;
+	}
+
+	/**
+	*	Convert token to variable
+	*	@return string
+	*	@param $str string
+	**/
+	function token($str) {
+		$fw=$this->fw;
+		$str=trim(preg_replace('/\{\{(.+?)\}\}/s',trim('\1'),
+			$fw->compile($str)));
+		if (preg_match('/^(.+)(?<!\|)\|((?:\h*\w+(?:\h*[,;]?))+)$/s',
+			$str,$parts)) {
+			$str=trim($parts[1]);
+			foreach ($fw->split(trim($parts[2],"\xC2\xA0")) as $func)
+				$str=is_string($cmd=$this->filter($func))?
+					$cmd.'('.$str.')':
+					'Base::instance()->'.
+					'call($this->filter(\''.$func.'\'),['.$str.'])';
+		}
+		return $str;
+	}
+
+	/**
+	*	Register or get (a specific one or all) token filters
+	*	@param string $key
+	*	@param string|closure $func
+	*	@return array|closure|string
+	*/
+	function filter($key=NULL,$func=NULL) {
+		if (!$key)
+			return array_keys($this->filter);
+		$key=strtolower($key);
+		if (!$func)
+			return $this->filter[$key];
+		$this->filter[$key]=$func;
+	}
+
+	/**
+	*	Assemble markup
+	*	@return string
+	*	@param $node string
+	**/
+	protected function build($node) {
+		return preg_replace_callback(
+			'/\{~(.+?)~\}|\{\*(.+?)\*\}|\{\-(.+?)\-\}|'.
+			'\{\{(.+?)\}\}((\r?\n)*)/s',
+			function($expr) {
+				if ($expr[1])
+					$str='<?php '.$this->token($expr[1]).' ?>';
+				elseif ($expr[2])
+					return '';
+				elseif ($expr[3])
+					$str=$expr[3];
+				else {
+					$str='<?= ('.trim($this->token($expr[4])).')'.
+						($this->interpolation?
+							(!empty($expr[6])?'."'.$expr[6].'"':''):'').' ?>';
+					if (isset($expr[5]))
+						$str.=$expr[5];
+				}
+				return $str;
+			},
+			$node
+		);
+	}
+
+	/**
+	*	Render template string
+	*	@return string
+	*	@param $node string|array
+	*	@param $hive array
+	*	@param $ttl int
+	*	@param $persist bool
+	*	@param $escape bool
+	**/
+	function resolve($node,array $hive=NULL,$ttl=0,$persist=FALSE,$escape=NULL) {
+		$fw=$this->fw;
+		$cache=Cache::instance();
+		if ($escape!==NULL) {
+			$esc=$fw->ESCAPE;
+			$fw->ESCAPE=$escape;
+		}
+		if ($ttl || $persist)
+			$hash=$fw->hash($fw->serialize($node));
+		if ($ttl && $cache->exists($hash,$data))
+			return $data;
+		if ($persist) {
+			if (!is_dir($tmp=$fw->TEMP))
+				mkdir($tmp,Base::MODE,TRUE);
+			if (!is_file($this->file=($tmp.
+				$fw->SEED.'.'.$hash.'.php')))
+				$fw->write($this->file,$this->build($node));
+			if (isset($_COOKIE[session_name()]) &&
+				!headers_sent() && session_status()!=PHP_SESSION_ACTIVE)
+				session_start();
+			$fw->sync('SESSION');
+			$data=$this->sandbox($hive);
+		}
+		else {
+			if (!$hive)
+				$hive=$fw->hive();
+			if ($fw->ESCAPE)
+				$hive=$this->esc($hive);
+			extract($hive);
+			unset($hive);
+			ob_start();
+			eval(' ?>'.$this->build($node).'<?php ');
+			$data=ob_get_clean();
+		}
+		if ($ttl)
+			$cache->set($hash,$data,$ttl);
+		if ($escape!==NULL)
+			$fw->ESCAPE=$esc;
+		return $data;
+	}
+
+	/**
+	 *	Parse template string
+	 *	@return string
+	 *	@param $text string
+	 **/
+	function parse($text) {
+		// Remove PHP code and comments
+		return preg_replace(
+			'/\h*<\?(?!xml)(?:php|\s*=)?.+?\?>\h*|'.
+			'\{\*.+?\*\}/is','', $text);
+	}
+
+	/**
+	*	Render template
+	*	@return string
+	*	@param $file string
+	*	@param $mime string
+	*	@param $hive array
+	*	@param $ttl int
+	**/
+	function render($file,$mime='text/html',array $hive=NULL,$ttl=0) {
+		$fw=$this->fw;
+		$cache=Cache::instance();
+		if (!is_dir($tmp=$fw->TEMP))
+			mkdir($tmp,Base::MODE,TRUE);
+		foreach ($fw->split($fw->UI) as $dir) {
+			if ($cache->exists($hash=$fw->hash($dir.$file),$data))
+				return $data;
+			if (is_file($view=$fw->fixslashes($dir.$file))) {
+				if (!is_file($this->file=($tmp.
+					$fw->SEED.'.'.$fw->hash($view).'.php')) ||
+					filemtime($this->file)<filemtime($view)) {
+					$contents=$fw->read($view);
+					if (isset($this->trigger['beforerender']))
+						foreach ($this->trigger['beforerender'] as $func)
+							$contents=$fw->call($func, [$contents, $view]);
+					$text=$this->parse($contents);
+					$fw->write($this->file,$this->build($text));
+				}
+				if (isset($_COOKIE[session_name()]) &&
+					!headers_sent() && session_status()!=PHP_SESSION_ACTIVE)
+					session_start();
+				$fw->sync('SESSION');
+				$data=$this->sandbox($hive,$mime);
+				if(isset($this->trigger['afterrender']))
+					foreach ($this->trigger['afterrender'] as $func)
+						$data=$fw->call($func, [$data, $view]);
+				if ($ttl)
+					$cache->set($hash,$data,$ttl);
+				return $data;
+			}
+		}
+		user_error(sprintf(Base::E_Open,$file),E_USER_ERROR);
+	}
+
+	/**
+	 *	post rendering handler
+	 *	@param $func callback
+	 */
+	function beforerender($func) {
+		$this->trigger['beforerender'][]=$func;
+	}
+
+}
+
+//! ISO language/country codes
+class ISO extends Prefab {
+
+	//@{ ISO 3166-1 country codes
+	const
+		CC_af='Afghanistan',
+		CC_ax='Åland Islands',
+		CC_al='Albania',
+		CC_dz='Algeria',
+		CC_as='American Samoa',
+		CC_ad='Andorra',
+		CC_ao='Angola',
+		CC_ai='Anguilla',
+		CC_aq='Antarctica',
+		CC_ag='Antigua and Barbuda',
+		CC_ar='Argentina',
+		CC_am='Armenia',
+		CC_aw='Aruba',
+		CC_au='Australia',
+		CC_at='Austria',
+		CC_az='Azerbaijan',
+		CC_bs='Bahamas',
+		CC_bh='Bahrain',
+		CC_bd='Bangladesh',
+		CC_bb='Barbados',
+		CC_by='Belarus',
+		CC_be='Belgium',
+		CC_bz='Belize',
+		CC_bj='Benin',
+		CC_bm='Bermuda',
+		CC_bt='Bhutan',
+		CC_bo='Bolivia',
+		CC_bq='Bonaire, Sint Eustatius and Saba',
+		CC_ba='Bosnia and Herzegovina',
+		CC_bw='Botswana',
+		CC_bv='Bouvet Island',
+		CC_br='Brazil',
+		CC_io='British Indian Ocean Territory',
+		CC_bn='Brunei Darussalam',
+		CC_bg='Bulgaria',
+		CC_bf='Burkina Faso',
+		CC_bi='Burundi',
+		CC_kh='Cambodia',
+		CC_cm='Cameroon',
+		CC_ca='Canada',
+		CC_cv='Cape Verde',
+		CC_ky='Cayman Islands',
+		CC_cf='Central African Republic',
+		CC_td='Chad',
+		CC_cl='Chile',
+		CC_cn='China',
+		CC_cx='Christmas Island',
+		CC_cc='Cocos (Keeling) Islands',
+		CC_co='Colombia',
+		CC_km='Comoros',
+		CC_cg='Congo',
+		CC_cd='Congo, The Democratic Republic of',
+		CC_ck='Cook Islands',
+		CC_cr='Costa Rica',
+		CC_ci='Côte d\'ivoire',
+		CC_hr='Croatia',
+		CC_cu='Cuba',
+		CC_cw='Curaçao',
+		CC_cy='Cyprus',
+		CC_cz='Czech Republic',
+		CC_dk='Denmark',
+		CC_dj='Djibouti',
+		CC_dm='Dominica',
+		CC_do='Dominican Republic',
+		CC_ec='Ecuador',
+		CC_eg='Egypt',
+		CC_sv='El Salvador',
+		CC_gq='Equatorial Guinea',
+		CC_er='Eritrea',
+		CC_ee='Estonia',
+		CC_et='Ethiopia',
+		CC_fk='Falkland Islands (Malvinas)',
+		CC_fo='Faroe Islands',
+		CC_fj='Fiji',
+		CC_fi='Finland',
+		CC_fr='France',
+		CC_gf='French Guiana',
+		CC_pf='French Polynesia',
+		CC_tf='French Southern Territories',
+		CC_ga='Gabon',
+		CC_gm='Gambia',
+		CC_ge='Georgia',
+		CC_de='Germany',
+		CC_gh='Ghana',
+		CC_gi='Gibraltar',
+		CC_gr='Greece',
+		CC_gl='Greenland',
+		CC_gd='Grenada',
+		CC_gp='Guadeloupe',
+		CC_gu='Guam',
+		CC_gt='Guatemala',
+		CC_gg='Guernsey',
+		CC_gn='Guinea',
+		CC_gw='Guinea-Bissau',
+		CC_gy='Guyana',
+		CC_ht='Haiti',
+		CC_hm='Heard Island and McDonald Islands',
+		CC_va='Holy See (Vatican City State)',
+		CC_hn='Honduras',
+		CC_hk='Hong Kong',
+		CC_hu='Hungary',
+		CC_is='Iceland',
+		CC_in='India',
+		CC_id='Indonesia',
+		CC_ir='Iran, Islamic Republic of',
+		CC_iq='Iraq',
+		CC_ie='Ireland',
+		CC_im='Isle of Man',
+		CC_il='Israel',
+		CC_it='Italy',
+		CC_jm='Jamaica',
+		CC_jp='Japan',
+		CC_je='Jersey',
+		CC_jo='Jordan',
+		CC_kz='Kazakhstan',
+		CC_ke='Kenya',
+		CC_ki='Kiribati',
+		CC_kp='Korea, Democratic People\'s Republic of',
+		CC_kr='Korea, Republic of',
+		CC_kw='Kuwait',
+		CC_kg='Kyrgyzstan',
+		CC_la='Lao People\'s Democratic Republic',
+		CC_lv='Latvia',
+		CC_lb='Lebanon',
+		CC_ls='Lesotho',
+		CC_lr='Liberia',
+		CC_ly='Libya',
+		CC_li='Liechtenstein',
+		CC_lt='Lithuania',
+		CC_lu='Luxembourg',
+		CC_mo='Macao',
+		CC_mk='Macedonia, The Former Yugoslav Republic of',
+		CC_mg='Madagascar',
+		CC_mw='Malawi',
+		CC_my='Malaysia',
+		CC_mv='Maldives',
+		CC_ml='Mali',
+		CC_mt='Malta',
+		CC_mh='Marshall Islands',
+		CC_mq='Martinique',
+		CC_mr='Mauritania',
+		CC_mu='Mauritius',
+		CC_yt='Mayotte',
+		CC_mx='Mexico',
+		CC_fm='Micronesia, Federated States of',
+		CC_md='Moldova, Republic of',
+		CC_mc='Monaco',
+		CC_mn='Mongolia',
+		CC_me='Montenegro',
+		CC_ms='Montserrat',
+		CC_ma='Morocco',
+		CC_mz='Mozambique',
+		CC_mm='Myanmar',
+		CC_na='Namibia',
+		CC_nr='Nauru',
+		CC_np='Nepal',
+		CC_nl='Netherlands',
+		CC_nc='New Caledonia',
+		CC_nz='New Zealand',
+		CC_ni='Nicaragua',
+		CC_ne='Niger',
+		CC_ng='Nigeria',
+		CC_nu='Niue',
+		CC_nf='Norfolk Island',
+		CC_mp='Northern Mariana Islands',
+		CC_no='Norway',
+		CC_om='Oman',
+		CC_pk='Pakistan',
+		CC_pw='Palau',
+		CC_ps='Palestinian Territory, Occupied',
+		CC_pa='Panama',
+		CC_pg='Papua New Guinea',
+		CC_py='Paraguay',
+		CC_pe='Peru',
+		CC_ph='Philippines',
+		CC_pn='Pitcairn',
+		CC_pl='Poland',
+		CC_pt='Portugal',
+		CC_pr='Puerto Rico',
+		CC_qa='Qatar',
+		CC_re='Réunion',
+		CC_ro='Romania',
+		CC_ru='Russian Federation',
+		CC_rw='Rwanda',
+		CC_bl='Saint Barthélemy',
+		CC_sh='Saint Helena, Ascension and Tristan da Cunha',
+		CC_kn='Saint Kitts and Nevis',
+		CC_lc='Saint Lucia',
+		CC_mf='Saint Martin (French Part)',
+		CC_pm='Saint Pierre and Miquelon',
+		CC_vc='Saint Vincent and The Grenadines',
+		CC_ws='Samoa',
+		CC_sm='San Marino',
+		CC_st='Sao Tome and Principe',
+		CC_sa='Saudi Arabia',
+		CC_sn='Senegal',
+		CC_rs='Serbia',
+		CC_sc='Seychelles',
+		CC_sl='Sierra Leone',
+		CC_sg='Singapore',
+		CC_sk='Slovakia',
+		CC_sx='Sint Maarten (Dutch Part)',
+		CC_si='Slovenia',
+		CC_sb='Solomon Islands',
+		CC_so='Somalia',
+		CC_za='South Africa',
+		CC_gs='South Georgia and The South Sandwich Islands',
+		CC_ss='South Sudan',
+		CC_es='Spain',
+		CC_lk='Sri Lanka',
+		CC_sd='Sudan',
+		CC_sr='Suriname',
+		CC_sj='Svalbard and Jan Mayen',
+		CC_sz='Swaziland',
+		CC_se='Sweden',
+		CC_ch='Switzerland',
+		CC_sy='Syrian Arab Republic',
+		CC_tw='Taiwan, Province of China',
+		CC_tj='Tajikistan',
+		CC_tz='Tanzania, United Republic of',
+		CC_th='Thailand',
+		CC_tl='Timor-Leste',
+		CC_tg='Togo',
+		CC_tk='Tokelau',
+		CC_to='Tonga',
+		CC_tt='Trinidad and Tobago',
+		CC_tn='Tunisia',
+		CC_tr='Turkey',
+		CC_tm='Turkmenistan',
+		CC_tc='Turks and Caicos Islands',
+		CC_tv='Tuvalu',
+		CC_ug='Uganda',
+		CC_ua='Ukraine',
+		CC_ae='United Arab Emirates',
+		CC_gb='United Kingdom',
+		CC_us='United States',
+		CC_um='United States Minor Outlying Islands',
+		CC_uy='Uruguay',
+		CC_uz='Uzbekistan',
+		CC_vu='Vanuatu',
+		CC_ve='Venezuela',
+		CC_vn='Viet Nam',
+		CC_vg='Virgin Islands, British',
+		CC_vi='Virgin Islands, U.S.',
+		CC_wf='Wallis and Futuna',
+		CC_eh='Western Sahara',
+		CC_ye='Yemen',
+		CC_zm='Zambia',
+		CC_zw='Zimbabwe';
+	//@}
+
+	//@{ ISO 639-1 language codes (Windows-compatibility subset)
+	const
+		LC_af='Afrikaans',
+		LC_am='Amharic',
+		LC_ar='Arabic',
+		LC_as='Assamese',
+		LC_ba='Bashkir',
+		LC_be='Belarusian',
+		LC_bg='Bulgarian',
+		LC_bn='Bengali',
+		LC_bo='Tibetan',
+		LC_br='Breton',
+		LC_ca='Catalan',
+		LC_co='Corsican',
+		LC_cs='Czech',
+		LC_cy='Welsh',
+		LC_da='Danish',
+		LC_de='German',
+		LC_dv='Divehi',
+		LC_el='Greek',
+		LC_en='English',
+		LC_es='Spanish',
+		LC_et='Estonian',
+		LC_eu='Basque',
+		LC_fa='Persian',
+		LC_fi='Finnish',
+		LC_fo='Faroese',
+		LC_fr='French',
+		LC_gd='Scottish Gaelic',
+		LC_gl='Galician',
+		LC_gu='Gujarati',
+		LC_he='Hebrew',
+		LC_hi='Hindi',
+		LC_hr='Croatian',
+		LC_hu='Hungarian',
+		LC_hy='Armenian',
+		LC_id='Indonesian',
+		LC_ig='Igbo',
+		LC_is='Icelandic',
+		LC_it='Italian',
+		LC_ja='Japanese',
+		LC_ka='Georgian',
+		LC_kk='Kazakh',
+		LC_km='Khmer',
+		LC_kn='Kannada',
+		LC_ko='Korean',
+		LC_lb='Luxembourgish',
+		LC_lo='Lao',
+		LC_lt='Lithuanian',
+		LC_lv='Latvian',
+		LC_mi='Maori',
+		LC_ml='Malayalam',
+		LC_mr='Marathi',
+		LC_ms='Malay',
+		LC_mt='Maltese',
+		LC_ne='Nepali',
+		LC_nl='Dutch',
+		LC_no='Norwegian',
+		LC_oc='Occitan',
+		LC_or='Oriya',
+		LC_pl='Polish',
+		LC_ps='Pashto',
+		LC_pt='Portuguese',
+		LC_qu='Quechua',
+		LC_ro='Romanian',
+		LC_ru='Russian',
+		LC_rw='Kinyarwanda',
+		LC_sa='Sanskrit',
+		LC_si='Sinhala',
+		LC_sk='Slovak',
+		LC_sl='Slovenian',
+		LC_sq='Albanian',
+		LC_sv='Swedish',
+		LC_ta='Tamil',
+		LC_te='Telugu',
+		LC_th='Thai',
+		LC_tk='Turkmen',
+		LC_tr='Turkish',
+		LC_tt='Tatar',
+		LC_uk='Ukrainian',
+		LC_ur='Urdu',
+		LC_vi='Vietnamese',
+		LC_wo='Wolof',
+		LC_yo='Yoruba',
+		LC_zh='Chinese';
+	//@}
+
+	/**
+	*	Return list of languages indexed by ISO 639-1 language code
+	*	@return array
+	**/
+	function languages() {
+		return \Base::instance()->constants($this,'LC_');
+	}
+
+	/**
+	*	Return list of countries indexed by ISO 3166-1 country code
+	*	@return array
+	**/
+	function countries() {
+		return \Base::instance()->constants($this,'CC_');
+	}
+
+}
+
+//! Container for singular object instances
+final class Registry {
+
+	private static
+		//! Object catalog
+		$table;
+
+	/**
+	*	Return TRUE if object exists in catalog
+	*	@return bool
+	*	@param $key string
+	**/
+	static function exists($key) {
+		return isset(self::$table[$key]);
+	}
+
+	/**
+	*	Add object to catalog
+	*	@return object
+	*	@param $key string
+	*	@param $obj object
+	**/
+	static function set($key,$obj) {
+		return self::$table[$key]=$obj;
+	}
+
+	/**
+	*	Retrieve object from catalog
+	*	@return object
+	*	@param $key string
+	**/
+	static function get($key) {
+		return self::$table[$key];
+	}
+
+	/**
+	*	Delete object from catalog
+	*	@return NULL
+	*	@param $key string
+	**/
+	static function clear($key) {
+		self::$table[$key]=NULL;
+		unset(self::$table[$key]);
+	}
+
+	//! Prohibit cloning
+	private function __clone() {
+	}
+
+	//! Prohibit instantiation
+	private function __construct() {
+	}
+
+}
+
+return Base::instance();

+ 238 - 0
lib/f3/basket.php

@@ -0,0 +1,238 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Session-based pseudo-mapper
+class Basket extends Magic {
+
+	//@{ Error messages
+	const
+		E_Field='Undefined field %s';
+	//@}
+
+	protected
+		//! Session key
+		$key,
+		//! Current item identifier
+		$id,
+		//! Current item contents
+		$item=[];
+
+	/**
+	*	Return TRUE if field is defined
+	*	@return bool
+	*	@param $key string
+	**/
+	function exists($key) {
+		return array_key_exists($key,$this->item);
+	}
+
+	/**
+	*	Assign value to field
+	*	@return scalar|FALSE
+	*	@param $key string
+	*	@param $val scalar
+	**/
+	function set($key,$val) {
+		return ($key=='_id')?FALSE:($this->item[$key]=$val);
+	}
+
+	/**
+	*	Retrieve value of field
+	*	@return scalar|FALSE
+	*	@param $key string
+	**/
+	function &get($key) {
+		if ($key=='_id')
+			return $this->id;
+		if (array_key_exists($key,$this->item))
+			return $this->item[$key];
+		user_error(sprintf(self::E_Field,$key),E_USER_ERROR);
+		return FALSE;
+	}
+
+	/**
+	*	Delete field
+	*	@return NULL
+	*	@param $key string
+	**/
+	function clear($key) {
+		unset($this->item[$key]);
+	}
+
+	/**
+	*	Return items that match key/value pair;
+	*	If no key/value pair specified, return all items
+	*	@return array
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	function find($key=NULL,$val=NULL) {
+		$out=[];
+		if (isset($_SESSION[$this->key])) {
+			foreach ($_SESSION[$this->key] as $id=>$item)
+				if (!isset($key) ||
+					array_key_exists($key,$item) && $item[$key]==$val) {
+					$obj=clone($this);
+					$obj->id=$id;
+					$obj->item=$item;
+					$out[]=$obj;
+				}
+		}
+		return $out;
+	}
+
+	/**
+	*	Return first item that matches key/value pair
+	*	@return object|FALSE
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	function findone($key,$val) {
+		return ($data=$this->find($key,$val))?$data[0]:FALSE;
+	}
+
+	/**
+	*	Map current item to matching key/value pair
+	*	@return array
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	function load($key,$val) {
+		if ($found=$this->find($key,$val)) {
+			$this->id=$found[0]->id;
+			return $this->item=$found[0]->item;
+		}
+		$this->reset();
+		return [];
+	}
+
+	/**
+	*	Return TRUE if current item is empty/undefined
+	*	@return bool
+	**/
+	function dry() {
+		return !$this->item;
+	}
+
+	/**
+	*	Return number of items in basket
+	*	@return int
+	**/
+	function count() {
+		return isset($_SESSION[$this->key])?count($_SESSION[$this->key]):0;
+	}
+
+	/**
+	*	Save current item
+	*	@return array
+	**/
+	function save() {
+		if (!$this->id)
+			$this->id=uniqid(NULL,TRUE);
+		$_SESSION[$this->key][$this->id]=$this->item;
+		return $this->item;
+	}
+
+	/**
+	*	Erase item matching key/value pair
+	*	@return bool
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	function erase($key,$val) {
+		$found=$this->find($key,$val);
+		if ($found && $id=$found[0]->id) {
+			unset($_SESSION[$this->key][$id]);
+			if ($id==$this->id)
+				$this->reset();
+			return TRUE;
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Reset cursor
+	*	@return NULL
+	**/
+	function reset() {
+		$this->id=NULL;
+		$this->item=[];
+	}
+
+	/**
+	*	Empty basket
+	*	@return NULL
+	**/
+	function drop() {
+		unset($_SESSION[$this->key]);
+	}
+
+	/**
+	*	Hydrate item using hive array variable
+	*	@return NULL
+	*	@param $var array|string
+	**/
+	function copyfrom($var) {
+		if (is_string($var))
+			$var=\Base::instance()->$var;
+		foreach ($var as $key=>$val)
+			$this->set($key,$val);
+	}
+
+	/**
+	*	Populate hive array variable with item contents
+	*	@return NULL
+	*	@param $key string
+	**/
+	function copyto($key) {
+		$var=&\Base::instance()->ref($key);
+		foreach ($this->item as $key=>$field)
+			$var[$key]=$field;
+	}
+
+	/**
+	*	Check out basket contents
+	*	@return array
+	**/
+	function checkout() {
+		if (isset($_SESSION[$this->key])) {
+			$out=$_SESSION[$this->key];
+			unset($_SESSION[$this->key]);
+			return $out;
+		}
+		return [];
+	}
+
+	/**
+	*	Instantiate class
+	*	@return void
+	*	@param $key string
+	**/
+	function __construct($key='basket') {
+		$this->key=$key;
+		if (session_status()!=PHP_SESSION_ACTIVE)
+			session_start();
+		Base::instance()->sync('SESSION');
+		$this->reset();
+	}
+
+}

+ 96 - 0
lib/f3/bcrypt.php

@@ -0,0 +1,96 @@
+<?php
+
+/**
+*	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+*
+*	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+*
+*	This is free software: you can redistribute it and/or modify it under the
+*	terms of the GNU General Public License as published by the Free Software
+*	Foundation, either version 3 of the License, or later.
+*
+*	Fat-Free Framework is distributed in the hope that it will be useful,
+*	but WITHOUT ANY WARRANTY; without even the implied warranty of
+*	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+*	General Public License for more details.
+*
+*	You should have received a copy of the GNU General Public License along
+*	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+*
+**/
+
+/**
+*	Lightweight password hashing library (PHP 5.5+ only)
+*	@deprecated Use http://php.net/manual/en/ref.password.php instead
+**/
+class Bcrypt extends Prefab {
+
+	//@{ Error messages
+	const
+		E_CostArg='Invalid cost parameter',
+		E_SaltArg='Salt must be at least 22 alphanumeric characters';
+	//@}
+
+	//! Default cost
+	const
+		COST=10;
+
+	/**
+	*	Generate bcrypt hash of string
+	*	@return string|FALSE
+	*	@param $pw string
+	*	@param $salt string
+	*	@param $cost int
+	**/
+	function hash($pw,$salt=NULL,$cost=self::COST) {
+		if ($cost<4 || $cost>31)
+			user_error(self::E_CostArg,E_USER_ERROR);
+		$len=22;
+		if ($salt) {
+			if (!preg_match('/^[[:alnum:]\.\/]{'.$len.',}$/',$salt))
+				user_error(self::E_SaltArg,E_USER_ERROR);
+		}
+		else {
+			$raw=16;
+			$iv='';
+			if (!$iv && extension_loaded('openssl'))
+				$iv=openssl_random_pseudo_bytes($raw);
+			if (!$iv)
+				for ($i=0;$i<$raw;$i++)
+					$iv.=chr(mt_rand(0,255));
+			$salt=str_replace('+','.',base64_encode($iv));
+		}
+		$salt=substr($salt,0,$len);
+		$hash=crypt($pw,sprintf('$2y$%02d$',$cost).$salt);
+		return strlen($hash)>13?$hash:FALSE;
+	}
+
+	/**
+	*	Check if password is still strong enough
+	*	@return bool
+	*	@param $hash string
+	*	@param $cost int
+	**/
+	function needs_rehash($hash,$cost=self::COST) {
+		list($pwcost)=sscanf($hash,"$2y$%d$");
+		return $pwcost<$cost;
+	}
+
+	/**
+	*	Verify password against hash using timing attack resistant approach
+	*	@return bool
+	*	@param $pw string
+	*	@param $hash string
+	**/
+	function verify($pw,$hash) {
+		$val=crypt($pw,$hash);
+		$len=strlen($val);
+		if ($len!=strlen($hash) || $len<14)
+			return FALSE;
+		$out=0;
+		for ($i=0;$i<$len;$i++)
+			$out|=(ord($val[$i])^ord($hash[$i]));
+		return $out===0;
+	}
+
+}

+ 516 - 0
lib/f3/cli/ws.php

@@ -0,0 +1,516 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace CLI;
+
+//! RFC6455 WebSocket server
+class WS {
+
+	const
+		//! UUID magic string
+		Magic='258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
+		//! Max packet size
+		Packet=65536;
+
+	//@{ Mask bits for first byte of header
+	const
+		Text=0x01,
+		Binary=0x02,
+		Close=0x08,
+		Ping=0x09,
+		Pong=0x0a,
+		OpCode=0x0f,
+		Finale=0x80;
+	//@}
+
+	//@{ Mask bits for second byte of header
+	const
+		Length=0x7f;
+	//@}
+
+	protected
+		$addr,
+		$ctx,
+		$wait,
+		$sockets,
+		$agents=[],
+		$events=[];
+
+	/**
+	*	Allocate stream socket
+	*	@return NULL
+	*	@param $socket resource
+	**/
+	function alloc($socket) {
+		if (is_bool($str=$this->read($socket))) {
+			$this->close($socket);
+			return;
+		}
+		// Get WebSocket headers
+		$hdrs=[];
+		$CRLF="\r\n";
+		$verb=NULL;
+		$uri=NULL;
+		foreach (explode($CRLF,trim($str)) as $line)
+			if (preg_match('/^(\w+)\s(.+)\sHTTP\/1\.\d$/',
+				trim($line),$match)) {
+				$verb=$match[1];
+				$uri=$match[2];
+			}
+			else
+			if (preg_match('/^(.+): (.+)/',trim($line),$match))
+				// Standardize header
+				$hdrs[
+					strtr(
+						ucwords(
+							strtolower(
+								strtr($match[1],'-',' ')
+							)
+						),' ','-'
+					)
+				]=$match[2];
+			else {
+				$this->close($socket);
+				return;
+			}
+		if (empty($hdrs['Upgrade']) &&
+			empty($hdrs['Sec-Websocket-Key'])) {
+			// Not a WebSocket request
+			if ($verb && $uri)
+				$this->write(
+					$socket,
+					$str='HTTP/1.1 400 Bad Request'.$CRLF.
+						'Connection: close'.$CRLF.$CRLF
+				);
+			$this->close($socket);
+			return;
+		}
+		// Handshake
+		$bytes=$this->write(
+			$socket,
+			$str='HTTP/1.1 101 Switching Protocols'.$CRLF.
+				'Upgrade: websocket'.$CRLF.
+				'Connection: Upgrade'.$CRLF.
+				'Sec-WebSocket-Accept: '.
+					base64_encode(
+						sha1(
+							$hdrs['Sec-Websocket-Key'].
+							self::Magic,
+							TRUE
+						)
+					).$CRLF.$CRLF
+		);
+		if ($bytes) {
+			// Connect agent to server
+			$this->sockets[]=$socket;
+			$this->agents[(int)$socket]=
+				new Agent($this,$socket,$verb,$uri,$hdrs);
+		}
+		else
+			$this->close($socket);
+	}
+
+	/**
+	*	Close stream socket
+	*	@return NULL
+	*	@param $socket resource
+	**/
+	function close($socket) {
+		stream_socket_shutdown($socket,STREAM_SHUT_WR);
+		@fclose($socket);
+	}
+
+	/**
+	*	Free stream socket
+	*	@return bool
+	*	@param $socket resource
+	**/
+	function free($socket) {
+		unset($this->sockets[array_search($socket,$this->sockets)]);
+		unset($this->agents[(int)$socket]);
+		$this->close($socket);
+	}
+
+	/**
+	*	Read from stream socket
+	*	@return string|FALSE
+	*	@param $socket resource
+	**/
+	function read($socket) {
+		if (is_string($str=@fread($socket,self::Packet)) &&
+			strlen($str) &&
+			strlen($str)<self::Packet)
+			return $str;
+		if (isset($this->events['error']) &&
+			is_callable($func=$this->events['error']))
+			$func($this);
+		return FALSE;
+	}
+
+	/**
+	*	Write to stream socket
+	*	@return int|FALSE
+	*	@param $socket resource
+	*	@param $str string
+	**/
+	function write($socket,$str) {
+		for ($i=0,$bytes=0;$i<strlen($str);$i+=$bytes) {
+			if (($bytes=@fwrite($socket,substr($str,$i))) &&
+				@fflush($socket))
+				continue;
+			if (isset($this->events['error']) &&
+				is_callable($func=$this->events['error']))
+				$func($this);
+			return FALSE;
+		}
+		return $bytes;
+	}
+
+	/**
+	*	Return socket agents
+	*	@return array
+	*	@param $uri string
+	***/
+	function agents($uri=NULL) {
+		return array_filter(
+			$this->agents,
+			function($val) use($uri) {
+				return $uri?($val->uri()==$uri):TRUE;
+			}
+		);
+	}
+
+	/**
+	*	Return event handlers
+	*	@return array
+	**/
+	function events() {
+		return $this->events;
+	}
+
+	/**
+	*	Bind function to event handler
+	*	@return object
+	*	@param $event string
+	*	@param $func callable
+	**/
+	function on($event,$func) {
+		$this->events[$event]=$func;
+		return $this;
+	}
+
+	/**
+	*	Terminate server
+	*	@return NULL
+	*	@param $signal int
+	**/
+	function kill($signal) {
+		die;
+	}
+
+	/**
+	*	Execute the server process
+	*	@return object
+	**/
+	function run() {
+		$fw=\Base::instance();
+		// Assign signal handlers
+		declare(ticks=1);
+		pcntl_signal(SIGINT,[$this,'kill']);
+		pcntl_signal(SIGTERM,[$this,'kill']);
+		gc_enable();
+		// Activate WebSocket listener
+		$listen=stream_socket_server(
+			$this->addr,$errno,$errstr,
+			STREAM_SERVER_BIND|STREAM_SERVER_LISTEN,
+			$this->ctx
+		);
+		$socket=socket_import_stream($listen);
+		register_shutdown_function(function() use($listen) {
+			foreach ($this->sockets as $socket)
+				if ($socket!=$listen)
+					$this->free($socket);
+			$this->close($listen);
+			if (isset($this->events['stop']) &&
+				is_callable($func=$this->events['stop']))
+				$func($this);
+		});
+		if ($errstr)
+			user_error($errstr,E_USER_ERROR);
+		if (isset($this->events['start']) &&
+			is_callable($func=$this->events['start']))
+			$func($this);
+		$this->sockets=[$listen];
+		$empty=[];
+		$wait=$this->wait;
+		while (TRUE) {
+			$active=$this->sockets;
+			$mark=microtime(TRUE);
+			$count=@stream_select(
+				$active,$empty,$empty,(int)$wait,round(1e6*($wait-(int)$wait))
+			);
+			if (is_bool($count) && $wait) {
+				if (isset($this->events['error']) &&
+					is_callable($func=$this->events['error']))
+					$func($this);
+				die;
+			}
+			if ($count) {
+				// Process active connections
+				foreach ($active as $socket) {
+					if (!is_resource($socket))
+						continue;
+					if ($socket==$listen) {
+						if ($socket=@stream_socket_accept($listen,0))
+							$this->alloc($socket);
+						else
+						if (isset($this->events['error']) &&
+							is_callable($func=$this->events['error']))
+							$func($this);
+					}
+					else {
+						$id=(int)$socket;
+						if (isset($this->agents[$id]) &&
+							$raw=$this->agents[$id]->fetch()) {
+							list($op,$data)=$raw;
+							// Dispatch
+							switch ($op & self::OpCode) {
+							case self::Ping:
+								$this->agents[$id]->send(self::Pong);
+								break;
+							case self::Close:
+								$this->free($socket);
+								break;
+							case self::Text:
+								$data=trim($data);
+							case self::Binary:
+								if (isset($this->events['receive']) &&
+									is_callable($func=$this->events['receive']))
+									$func($this->agents[$id],$op,$data);
+								break;
+							}
+						}
+					}
+				}
+				$wait-=microtime(TRUE)-$mark;
+				while ($wait<1e-6) {
+					$wait+=$this->wait;
+					$count=0;
+				}
+			}
+			if (!$count) {
+				$mark=microtime(TRUE);
+				foreach ($this->sockets as $socket) {
+					if (!is_resource($socket))
+						continue;
+					$id=(int)$socket;
+					if ($socket!=$listen &&
+						isset($this->agents[$id]) &&
+						isset($this->events['idle']) &&
+						is_callable($func=$this->events['idle']))
+						$func($this->agents[$id]);
+				}
+				$wait=$this->wait-microtime(TRUE)+$mark;
+			}
+			gc_collect_cycles();
+		}
+	}
+
+	/**
+	*	Instantiate object
+	*	@return object
+	*	@param $addr string
+	*	@param $ctx resource
+	*	@param $wait int
+	**/
+	function __construct($addr,$ctx=NULL,$wait=60) {
+		$this->addr=$addr;
+		$this->ctx=$ctx?:stream_context_create();
+		$this->wait=$wait;
+		$this->events=[];
+	}
+
+}
+
+//! RFC6455 remote socket
+class Agent {
+
+	protected
+		$server,
+		$id,
+		$socket,
+		$flag,
+		$verb,
+		$uri,
+		$headers,
+		$events,
+		$buffer;
+
+	/**
+	*	Return server instance
+	*	@return object
+	**/
+	function server() {
+		return $this->server;
+	}
+
+	/**
+	*	Return socket ID
+	*	@return string
+	**/
+	function id() {
+		return $this->id;
+	}
+
+	/**
+	*	Return request method
+	*	@return string
+	**/
+	function verb() {
+		return $this->verb;
+	}
+
+	/**
+	*	Return request URI
+	*	@return string
+	**/
+	function uri() {
+		return $this->uri;
+	}
+
+	/**
+	*	Return socket headers
+	*	@return string
+	**/
+	function headers() {
+		return $this->headers;
+	}
+
+	/**
+	*	Frame and transmit payload
+	*	@return string|FALSE
+	*	@param $socket resource
+	*	@param $op int
+	*	@param $payload string
+	**/
+	function send($op,$data='') {
+		$mask=WS::Finale | $op & WS::OpCode;
+		$len=strlen($data);
+		$str='';
+		if ($len>0xffff)
+			$str=pack('CCNN',$mask,0x7f,$len);
+		else
+		if ($len>0x7d)
+			$str=pack('CCn',$mask,0x7e,$len);
+		else
+			$str=pack('CC',$mask,$len);
+		$str.=$data;
+		$server=$this->server();
+		if (is_bool($server->write($this->socket,$str))) {
+			$this->free();
+			return FALSE;
+		}
+		if (!in_array($op,[WS::Pong,WS::Close]) &&
+			isset($this->events['send']) &&
+			is_callable($func=$this->events['send']))
+			$func($this,$op,$data);
+		return $data;
+	}
+
+	/**
+	*	Retrieve and unmask payload
+	*	@return array|FALSE
+	**/
+	function fetch() {
+		// Unmask payload
+		$server=$this->server();
+		if (is_bool($buf=$server->read($this->socket))) {
+			$this->free();
+			return FALSE;
+		}
+		$buf=($this->buffer.=$buf);
+		$op=ord($buf[0]) & WS::OpCode;
+		$len=ord($buf[1]) & WS::Length;
+		$pos=2;
+		if ($len==0x7e) {
+			$len=ord($buf[2])*256+ord($buf[3]);
+			$pos+=2;
+		}
+		else
+		if ($len==0x7f) {
+			for ($i=0,$len=0;$i<8;$i++)
+				$len=$len*256+ord($buf[$i+2]);
+			$pos+=8;
+		}
+		for ($i=0,$mask=[];$i<4;$i++)
+			$mask[$i]=ord($buf[$pos+$i]);
+		$pos+=4;
+		if (strlen($buf)<$len+$pos)
+			return FALSE;
+		for ($i=0,$data='';$i<$len;$i++)
+			$data.=chr(ord($buf[$pos+$i])^$mask[$i%4]);
+		$this->buffer='';
+		return [$op,$data];
+	}
+
+	/**
+	*	Free stream socket
+	*	@return NULL
+	**/
+	function free() {
+		$this->server->free($this->socket);
+	}
+
+	/**
+	*	Destroy object
+	*	@return NULL
+	**/
+	function __destruct() {
+		if (isset($this->events['disconnect']) &&
+			is_callable($func=$this->events['disconnect']))
+			$func($this);
+	}
+
+	/**
+	*	Instantiate object
+	*	@return object
+	*	@param $server object
+	*	@param $socket resource
+	*	@param $verb string
+	*	@param $uri string
+	*	@param $hdrs array
+	**/
+	function __construct($server,$socket,$verb,$uri,array $hdrs) {
+		$this->server=$server;
+		$this->id=stream_socket_get_name($socket,TRUE);
+		$this->socket=$socket;
+		$this->verb=$verb;
+		$this->uri=$uri;
+		$this->headers=$hdrs;
+		$this->events=$server->events();
+		$this->buffer='';
+		if (isset($this->events['connect']) &&
+			is_callable($func=$this->events['connect']))
+			$func($this);
+	}
+
+}

+ 1 - 0
lib/f3/code.css

@@ -0,0 +1 @@
+code{word-wrap:break-word;color:black}.comment,.doc_comment,.ml_comment{color:dimgray;font-style:italic}.variable{color:blueviolet}.const,.constant_encapsed_string,.class_c,.dir,.file,.func_c,.halt_compiler,.line,.method_c,.lnumber,.dnumber{color:crimson}.string,.and_equal,.boolean_and,.boolean_or,.concat_equal,.dec,.div_equal,.inc,.is_equal,.is_greater_or_equal,.is_identical,.is_not_equal,.is_not_identical,.is_smaller_or_equal,.logical_and,.logical_or,.logical_xor,.minus_equal,.mod_equal,.mul_equal,.ns_c,.ns_separator,.or_equal,.plus_equal,.sl,.sl_equal,.sr,.sr_equal,.xor_equal,.start_heredoc,.end_heredoc,.object_operator,.paamayim_nekudotayim{color:black}.abstract,.array,.array_cast,.as,.break,.case,.catch,.class,.clone,.continue,.declare,.default,.do,.echo,.else,.elseif,.empty.enddeclare,.endfor,.endforach,.endif,.endswitch,.endwhile,.eval,.exit,.extends,.final,.for,.foreach,.function,.global,.goto,.if,.implements,.include,.include_once,.instanceof,.interface,.isset,.list,.namespace,.new,.print,.private,.public,.protected,.require,.require_once,.return,.static,.switch,.throw,.try,.unset,.use,.var,.while{color:royalblue}.open_tag,.open_tag_with_echo,.close_tag{color:orange}.ini_section{color:black}.ini_key{color:royalblue}.ini_value{color:crimson}.xml_tag{color:dodgerblue}.xml_attr{color:blueviolet}.xml_data{color:red}.section{color:black}.directive{color:blue}.data{color:dimgray}

+ 388 - 0
lib/f3/db/cursor.php

@@ -0,0 +1,388 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB;
+
+//! Simple cursor implementation
+abstract class Cursor extends \Magic implements \IteratorAggregate {
+
+	//@{ Error messages
+	const
+		E_Field='Undefined field %s';
+	//@}
+
+	protected
+		//! Query results
+		$query=[],
+		//! Current position
+		$ptr=0,
+		//! Event listeners
+		$trigger=[];
+
+	/**
+	*	Return database type
+	*	@return string
+	**/
+	abstract function dbtype();
+
+	/**
+	*	Return field names
+	*	@return array
+	**/
+	abstract function fields();
+
+	/**
+	*	Return fields of mapper object as an associative array
+	*	@return array
+	*	@param $obj object
+	**/
+	abstract function cast($obj=NULL);
+
+	/**
+	*	Return records (array of mapper objects) that match criteria
+	*	@return array
+	*	@param $filter string|array
+	*	@param $options array
+	*	@param $ttl int
+	**/
+	abstract function find($filter=NULL,array $options=NULL,$ttl=0);
+
+	/**
+	*	Count records that match criteria
+	*	@return int
+	*	@param $filter array
+	*	@param $options array
+	*	@param $ttl int
+	**/
+	abstract function count($filter=NULL,array $options=NULL,$ttl=0);
+
+	/**
+	*	Insert new record
+	*	@return array
+	**/
+	abstract function insert();
+
+	/**
+	*	Update current record
+	*	@return array
+	**/
+	abstract function update();
+
+	/**
+	*	Hydrate mapper object using hive array variable
+	*	@return NULL
+	*	@param $var array|string
+	*	@param $func callback
+	**/
+	abstract function copyfrom($var,$func=NULL);
+
+	/**
+	*	Populate hive array variable with mapper fields
+	*	@return NULL
+	*	@param $key string
+	**/
+	abstract function copyto($key);
+
+	/**
+	*	Get cursor's equivalent external iterator
+	*	Causes a fatal error in PHP 5.3.5 if uncommented
+	*	return ArrayIterator
+	**/
+	abstract function getiterator();
+
+
+	/**
+	*	Return TRUE if current cursor position is not mapped to any record
+	*	@return bool
+	**/
+	function dry() {
+		return empty($this->query[$this->ptr]);
+	}
+
+	/**
+	*	Return first record (mapper object) that matches criteria
+	*	@return static|FALSE
+	*	@param $filter string|array
+	*	@param $options array
+	*	@param $ttl int
+	**/
+	function findone($filter=NULL,array $options=NULL,$ttl=0) {
+		if (!$options)
+			$options=[];
+		// Override limit
+		$options['limit']=1;
+		return ($data=$this->find($filter,$options,$ttl))?$data[0]:FALSE;
+	}
+
+	/**
+	*	Return array containing subset of records matching criteria,
+	*	total number of records in superset, specified limit, number of
+	*	subsets available, and actual subset position
+	*	@return array
+	*	@param $pos int
+	*	@param $size int
+	*	@param $filter string|array
+	*	@param $options array
+	*	@param $ttl int
+	*	@param $bounce bool
+	**/
+	function paginate(
+		$pos=0,$size=10,$filter=NULL,array $options=NULL,$ttl=0,$bounce=TRUE) {
+		$total=$this->count($filter,$options,$ttl);
+		$count=ceil($total/$size);
+		if ($bounce)
+			$pos=max(0,min($pos,$count-1));
+		return [
+			'subset'=>($bounce || $pos<$count)?$this->find($filter,
+				array_merge(
+					$options?:[],
+					['limit'=>$size,'offset'=>$pos*$size]
+				),
+				$ttl
+			):[],
+			'total'=>$total,
+			'limit'=>$size,
+			'count'=>$count,
+			'pos'=>$bounce?($pos<$count?$pos:0):$pos
+		];
+	}
+
+	/**
+	*	Map to first record that matches criteria
+	*	@return array|FALSE
+	*	@param $filter string|array
+	*	@param $options array
+	*	@param $ttl int
+	**/
+	function load($filter=NULL,array $options=NULL,$ttl=0) {
+		$this->reset();
+		return ($this->query=$this->find($filter,$options,$ttl)) &&
+			$this->skip(0)?$this->query[$this->ptr]:FALSE;
+	}
+
+	/**
+	*	Return the count of records loaded
+	*	@return int
+	**/
+	function loaded() {
+		return count($this->query);
+	}
+
+	/**
+	*	Map to first record in cursor
+	*	@return mixed
+	**/
+	function first() {
+		return $this->skip(-$this->ptr);
+	}
+
+	/**
+	*	Map to last record in cursor
+	*	@return mixed
+	**/
+	function last() {
+		return $this->skip(($ofs=count($this->query)-$this->ptr)?$ofs-1:0);
+	}
+
+	/**
+	*	Map to nth record relative to current cursor position
+	*	@return mixed
+	*	@param $ofs int
+	**/
+	function skip($ofs=1) {
+		$this->ptr+=$ofs;
+		return $this->ptr>-1 && $this->ptr<count($this->query)?
+			$this->query[$this->ptr]:FALSE;
+	}
+
+	/**
+	*	Map next record
+	*	@return mixed
+	**/
+	function next() {
+		return $this->skip();
+	}
+
+	/**
+	*	Map previous record
+	*	@return mixed
+	**/
+	function prev() {
+		return $this->skip(-1);
+	}
+
+	/**
+	 * Return whether current iterator position is valid.
+	 */
+	function valid() {
+		return !$this->dry();
+	}
+
+	/**
+	*	Save mapped record
+	*	@return mixed
+	**/
+	function save() {
+		return $this->query?$this->update():$this->insert();
+	}
+
+	/**
+	*	Delete current record
+	*	@return int|bool
+	**/
+	function erase() {
+		$this->query=array_slice($this->query,0,$this->ptr,TRUE)+
+			array_slice($this->query,$this->ptr,NULL,TRUE);
+		$this->skip(0);
+	}
+
+	/**
+	*	Define onload trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function onload($func) {
+		return $this->trigger['load']=$func;
+	}
+
+	/**
+	*	Define beforeinsert trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function beforeinsert($func) {
+		return $this->trigger['beforeinsert']=$func;
+	}
+
+	/**
+	*	Define afterinsert trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function afterinsert($func) {
+		return $this->trigger['afterinsert']=$func;
+	}
+
+	/**
+	*	Define oninsert trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function oninsert($func) {
+		return $this->afterinsert($func);
+	}
+
+	/**
+	*	Define beforeupdate trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function beforeupdate($func) {
+		return $this->trigger['beforeupdate']=$func;
+	}
+
+	/**
+	*	Define afterupdate trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function afterupdate($func) {
+		return $this->trigger['afterupdate']=$func;
+	}
+
+	/**
+	*	Define onupdate trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function onupdate($func) {
+		return $this->afterupdate($func);
+	}
+
+	/**
+	*	Define beforesave trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function beforesave($func) {
+		$this->trigger['beforeinsert']=$func;
+		$this->trigger['beforeupdate']=$func;
+		return $func;
+	}
+
+	/**
+	*	Define aftersave trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function aftersave($func) {
+		$this->trigger['afterinsert']=$func;
+		$this->trigger['afterupdate']=$func;
+		return $func;
+	}
+
+	/**
+	*	Define onsave trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function onsave($func) {
+		return $this->aftersave($func);
+	}
+
+	/**
+	*	Define beforeerase trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function beforeerase($func) {
+		return $this->trigger['beforeerase']=$func;
+	}
+
+	/**
+	*	Define aftererase trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function aftererase($func) {
+		return $this->trigger['aftererase']=$func;
+	}
+
+	/**
+	*	Define onerase trigger
+	*	@return callback
+	*	@param $func callback
+	**/
+	function onerase($func) {
+		return $this->aftererase($func);
+	}
+
+	/**
+	*	Reset cursor
+	*	@return NULL
+	**/
+	function reset() {
+		$this->query=[];
+		$this->ptr=0;
+	}
+
+}

+ 175 - 0
lib/f3/db/jig.php

@@ -0,0 +1,175 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB;
+
+//! In-memory/flat-file DB wrapper
+class Jig {
+
+	//@{ Storage formats
+	const
+		FORMAT_JSON=0,
+		FORMAT_Serialized=1;
+	//@}
+
+	protected
+		//! UUID
+		$uuid,
+		//! Storage location
+		$dir,
+		//! Current storage format
+		$format,
+		//! Jig log
+		$log,
+		//! Memory-held data
+		$data,
+		//! lazy load/save files
+		$lazy;
+
+	/**
+	*	Read data from memory/file
+	*	@return array
+	*	@param $file string
+	**/
+	function &read($file) {
+		if (!$this->dir || !is_file($dst=$this->dir.$file)) {
+			if (!isset($this->data[$file]))
+				$this->data[$file]=[];
+			return $this->data[$file];
+		}
+		if ($this->lazy && isset($this->data[$file]))
+			return $this->data[$file];
+		$fw=\Base::instance();
+		$raw=$fw->read($dst);
+		switch ($this->format) {
+			case self::FORMAT_JSON:
+				$data=json_decode($raw,TRUE);
+				break;
+			case self::FORMAT_Serialized:
+				$data=$fw->unserialize($raw);
+				break;
+		}
+		$this->data[$file] = $data;
+		return $this->data[$file];
+	}
+
+	/**
+	*	Write data to memory/file
+	*	@return int
+	*	@param $file string
+	*	@param $data array
+	**/
+	function write($file,array $data=NULL) {
+		if (!$this->dir || $this->lazy)
+			return count($this->data[$file]=$data);
+		$fw=\Base::instance();
+		switch ($this->format) {
+			case self::FORMAT_JSON:
+				$out=json_encode($data,JSON_PRETTY_PRINT);
+				break;
+			case self::FORMAT_Serialized:
+				$out=$fw->serialize($data);
+				break;
+		}
+		return $fw->write($this->dir.'/'.$file,$out);
+	}
+
+	/**
+	*	Return directory
+	*	@return string
+	**/
+	function dir() {
+		return $this->dir;
+	}
+
+	/**
+	*	Return UUID
+	*	@return string
+	**/
+	function uuid() {
+		return $this->uuid;
+	}
+
+	/**
+	*	Return profiler results (or disable logging)
+	*	@param $flag bool
+	*	@return string
+	**/
+	function log($flag=TRUE) {
+		if ($flag)
+			return $this->log;
+		$this->log=FALSE;
+	}
+
+	/**
+	*	Jot down log entry
+	*	@return NULL
+	*	@param $frame string
+	**/
+	function jot($frame) {
+		if ($frame)
+			$this->log.=date('r').' '.$frame.PHP_EOL;
+	}
+
+	/**
+	*	Clean storage
+	*	@return NULL
+	**/
+	function drop() {
+		if ($this->lazy) // intentional
+			$this->data=[];
+		if (!$this->dir)
+			$this->data=[];
+		elseif ($glob=@glob($this->dir.'/*',GLOB_NOSORT))
+			foreach ($glob as $file)
+				@unlink($file);
+	}
+
+	//! Prohibit cloning
+	private function __clone() {
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $dir string
+	*	@param $format int
+	**/
+	function __construct($dir=NULL,$format=self::FORMAT_JSON,$lazy=FALSE) {
+		if ($dir && !is_dir($dir))
+			mkdir($dir,\Base::MODE,TRUE);
+		$this->uuid=\Base::instance()->hash($this->dir=$dir);
+		$this->format=$format;
+		$this->lazy=$lazy;
+	}
+
+	/**
+	*	save file on destruction
+	**/
+	function __destruct() {
+		if ($this->lazy) {
+			$this->lazy = FALSE;
+			foreach ($this->data as $file => $data)
+				$this->write($file,$data);
+		}
+	}
+
+}

+ 538 - 0
lib/f3/db/jig/mapper.php

@@ -0,0 +1,538 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB\Jig;
+
+//! Flat-file DB mapper
+class Mapper extends \DB\Cursor {
+
+	protected
+		//! Flat-file DB wrapper
+		$db,
+		//! Data file
+		$file,
+		//! Document identifier
+		$id,
+		//! Document contents
+		$document=[],
+		//! field map-reduce handlers
+		$_reduce;
+
+	/**
+	*	Return database type
+	*	@return string
+	**/
+	function dbtype() {
+		return 'Jig';
+	}
+
+	/**
+	*	Return TRUE if field is defined
+	*	@return bool
+	*	@param $key string
+	**/
+	function exists($key) {
+		return array_key_exists($key,$this->document);
+	}
+
+	/**
+	*	Assign value to field
+	*	@return scalar|FALSE
+	*	@param $key string
+	*	@param $val scalar
+	**/
+	function set($key,$val) {
+		return ($key=='_id')?FALSE:($this->document[$key]=$val);
+	}
+
+	/**
+	*	Retrieve value of field
+	*	@return scalar|FALSE
+	*	@param $key string
+	**/
+	function &get($key) {
+		if ($key=='_id')
+			return $this->id;
+		if (array_key_exists($key,$this->document))
+			return $this->document[$key];
+		user_error(sprintf(self::E_Field,$key),E_USER_ERROR);
+	}
+
+	/**
+	*	Delete field
+	*	@return NULL
+	*	@param $key string
+	**/
+	function clear($key) {
+		if ($key!='_id')
+			unset($this->document[$key]);
+	}
+
+	/**
+	*	Convert array to mapper object
+	*	@return object
+	*	@param $id string
+	*	@param $row array
+	**/
+	protected function factory($id,$row) {
+		$mapper=clone($this);
+		$mapper->reset();
+		$mapper->id=$id;
+		foreach ($row as $field=>$val)
+			$mapper->document[$field]=$val;
+		$mapper->query=[clone($mapper)];
+		if (isset($mapper->trigger['load']))
+			\Base::instance()->call($mapper->trigger['load'],$mapper);
+		return $mapper;
+	}
+
+	/**
+	*	Return fields of mapper object as an associative array
+	*	@return array
+	*	@param $obj object
+	**/
+	function cast($obj=NULL) {
+		if (!$obj)
+			$obj=$this;
+		return $obj->document+['_id'=>$this->id];
+	}
+
+	/**
+	*	Convert tokens in string expression to variable names
+	*	@return string
+	*	@param $str string
+	**/
+	function token($str) {
+		$str=preg_replace_callback(
+			'/(?<!\w)@(\w(?:[\w\.\[\]])*)/',
+			function($token) {
+				// Convert from JS dot notation to PHP array notation
+				return '$'.preg_replace_callback(
+					'/(\.\w+)|\[((?:[^\[\]]*|(?R))*)\]/',
+					function($expr) {
+						$fw=\Base::instance();
+						return
+							'['.
+							($expr[1]?
+								$fw->stringify(substr($expr[1],1)):
+								(preg_match('/^\w+/',
+									$mix=$this->token($expr[2]))?
+									$fw->stringify($mix):
+									$mix)).
+							']';
+					},
+					$token[1]
+				);
+			},
+			$str
+		);
+		return trim($str);
+	}
+
+	/**
+	*	Return records that match criteria
+	*	@return static[]|FALSE
+	*	@param $filter array
+	*	@param $options array
+	*	@param $ttl int
+	*	@param $log bool
+	**/
+	function find($filter=NULL,array $options=NULL,$ttl=0,$log=TRUE) {
+		if (!$options)
+			$options=[];
+		$options+=[
+			'order'=>NULL,
+			'limit'=>0,
+			'offset'=>0,
+			'group'=>NULL,
+		];
+		$fw=\Base::instance();
+		$cache=\Cache::instance();
+		$db=$this->db;
+		$now=microtime(TRUE);
+		$data=[];
+		if (!$fw->CACHE || !$ttl || !($cached=$cache->exists(
+			$hash=$fw->hash($this->db->dir().
+				$fw->stringify([$filter,$options])).'.jig',$data)) ||
+			$cached[0]+$ttl<microtime(TRUE)) {
+			$data=$db->read($this->file);
+			if (is_null($data))
+				return FALSE;
+			foreach ($data as $id=>&$doc) {
+				$doc['_id']=$id;
+				unset($doc);
+			}
+			if ($filter) {
+				if (!is_array($filter))
+					return FALSE;
+				// Normalize equality operator
+				$expr=preg_replace('/(?<=[^<>!=])=(?!=)/','==',$filter[0]);
+				// Prepare query arguments
+				$args=isset($filter[1]) && is_array($filter[1])?
+					$filter[1]:
+					array_slice($filter,1,NULL,TRUE);
+				$args=is_array($args)?$args:[1=>$args];
+				$keys=$vals=[];
+				$tokens=array_slice(
+					token_get_all('<?php '.$this->token($expr)),1);
+				$data=array_filter($data,
+					function($_row) use($fw,$args,$tokens) {
+						$_expr='';
+						$ctr=0;
+						$named=FALSE;
+						foreach ($tokens as $token) {
+							if (is_string($token))
+								if ($token=='?') {
+									// Positional
+									$ctr++;
+									$key=$ctr;
+								}
+								else {
+									if ($token==':')
+										$named=TRUE;
+									else
+										$_expr.=$token;
+									continue;
+								}
+							elseif ($named &&
+								token_name($token[0])=='T_STRING') {
+								$key=':'.$token[1];
+								$named=FALSE;
+							}
+							else {
+								$_expr.=$token[1];
+								continue;
+							}
+							$_expr.=$fw->stringify(
+								is_string($args[$key])?
+									addcslashes($args[$key],'\''):
+									$args[$key]);
+						}
+						// Avoid conflict with user code
+						unset($fw,$tokens,$args,$ctr,$token,$key,$named);
+						extract($_row);
+						// Evaluate pseudo-SQL expression
+						return eval('return '.$_expr.';');
+					}
+				);
+			}
+			if (isset($options['group'])) {
+				$cols=array_reverse($fw->split($options['group']));
+				// sort into groups
+				$data=$this->sort($data,$options['group']);
+				foreach($data as $i=>&$row) {
+					if (!isset($prev)) {
+						$prev=$row;
+						$prev_i=$i;
+					}
+					$drop=false;
+					foreach ($cols as $col)
+						if ($prev_i!=$i && array_key_exists($col,$row) &&
+							array_key_exists($col,$prev) && $row[$col]==$prev[$col])
+							// reduce/modify
+							$drop=!isset($this->_reduce[$col]) || call_user_func_array(
+								$this->_reduce[$col][0],[&$prev,&$row])!==FALSE;
+						elseif (isset($this->_reduce[$col])) {
+							$null=null;
+							// initial
+							call_user_func_array($this->_reduce[$col][0],[&$row,&$null]);
+						}
+					if ($drop)
+						unset($data[$i]);
+					else {
+						$prev=&$row;
+						$prev_i=$i;
+					}
+					unset($row);
+				}
+				// finalize
+				if ($this->_reduce[$col][1])
+					foreach($data as $i=>&$row) {
+						$row=call_user_func($this->_reduce[$col][1],$row);
+						if (!$row)
+							unset($data[$i]);
+						unset($row);
+					}
+			}
+			if (isset($options['order']))
+				$data=$this->sort($data,$options['order']);
+			$data=array_slice($data,
+				$options['offset'],$options['limit']?:NULL,TRUE);
+			if ($fw->CACHE && $ttl)
+				// Save to cache backend
+				$cache->set($hash,$data,$ttl);
+		}
+		$out=[];
+		foreach ($data as $id=>&$doc) {
+			unset($doc['_id']);
+			$out[]=$this->factory($id,$doc);
+			unset($doc);
+		}
+		if ($log && isset($args)) {
+			if ($filter)
+				foreach ($args as $key=>$val) {
+					$vals[]=$fw->stringify(is_array($val)?$val[0]:$val);
+					$keys[]='/'.(is_numeric($key)?'\?':preg_quote($key)).'/';
+				}
+			$db->jot('('.sprintf('%.1f',1e3*(microtime(TRUE)-$now)).'ms) '.
+				$this->file.' [find] '.
+				($filter?preg_replace($keys,$vals,$filter[0],1):''));
+		}
+		return $out;
+	}
+
+	/**
+	*	Sort a collection
+	*	@param $data
+	*	@param $cond
+	*	@return mixed
+	*/
+	protected function sort($data,$cond) {
+		$cols=\Base::instance()->split($cond);
+		uasort(
+			$data,
+			function($val1,$val2) use($cols) {
+				foreach ($cols as $col) {
+					$parts=explode(' ',$col,2);
+					$order=empty($parts[1])?
+						SORT_ASC:
+						constant($parts[1]);
+					$col=$parts[0];
+					if (!array_key_exists($col,$val1))
+						$val1[$col]=NULL;
+					if (!array_key_exists($col,$val2))
+						$val2[$col]=NULL;
+					list($v1,$v2)=[$val1[$col],$val2[$col]];
+					if ($out=strnatcmp($v1,$v2)*
+						(($order==SORT_ASC)*2-1))
+						return $out;
+				}
+				return 0;
+			}
+		);
+		return $data;
+	}
+
+	/**
+	*	Add reduce handler for grouped fields
+	*	@param $key string
+	*	@param $handler callback
+	*	@param $finalize callback
+	*/
+	function reduce($key,$handler,$finalize=null){
+		$this->_reduce[$key]=[$handler,$finalize];
+	}
+
+	/**
+	*	Count records that match criteria
+	*	@return int
+	*	@param $filter array
+	*	@param $options array
+	*	@param $ttl int
+	**/
+	function count($filter=NULL,array $options=NULL,$ttl=0) {
+		$now=microtime(TRUE);
+		$out=count($this->find($filter,$options,$ttl,FALSE));
+		$this->db->jot('('.sprintf('%.1f',1e3*(microtime(TRUE)-$now)).'ms) '.
+			$this->file.' [count] '.($filter?json_encode($filter):''));
+		return $out;
+	}
+
+	/**
+	*	Return record at specified offset using criteria of previous
+	*	load() call and make it active
+	*	@return array
+	*	@param $ofs int
+	**/
+	function skip($ofs=1) {
+		$this->document=($out=parent::skip($ofs))?$out->document:[];
+		$this->id=$out?$out->id:NULL;
+		if ($this->document && isset($this->trigger['load']))
+			\Base::instance()->call($this->trigger['load'],$this);
+		return $out;
+	}
+
+	/**
+	*	Insert new record
+	*	@return array
+	**/
+	function insert() {
+		if ($this->id)
+			return $this->update();
+		$db=$this->db;
+		$now=microtime(TRUE);
+		while (($id=uniqid(NULL,TRUE)) &&
+			($data=&$db->read($this->file)) && isset($data[$id]) &&
+			!connection_aborted())
+			usleep(mt_rand(0,100));
+		$this->id=$id;
+		$pkey=['_id'=>$this->id];
+		if (isset($this->trigger['beforeinsert']) &&
+			\Base::instance()->call($this->trigger['beforeinsert'],
+				[$this,$pkey])===FALSE)
+			return $this->document;
+		$data[$id]=$this->document;
+		$db->write($this->file,$data);
+		$db->jot('('.sprintf('%.1f',1e3*(microtime(TRUE)-$now)).'ms) '.
+			$this->file.' [insert] '.json_encode($this->document));
+		if (isset($this->trigger['afterinsert']))
+			\Base::instance()->call($this->trigger['afterinsert'],
+				[$this,$pkey]);
+		$this->load(['@_id=?',$this->id]);
+		return $this->document;
+	}
+
+	/**
+	*	Update current record
+	*	@return array
+	**/
+	function update() {
+		$db=$this->db;
+		$now=microtime(TRUE);
+		$data=&$db->read($this->file);
+		if (isset($this->trigger['beforeupdate']) &&
+			\Base::instance()->call($this->trigger['beforeupdate'],
+				[$this,['_id'=>$this->id]])===FALSE)
+			return $this->document;
+		$data[$this->id]=$this->document;
+		$db->write($this->file,$data);
+		$db->jot('('.sprintf('%.1f',1e3*(microtime(TRUE)-$now)).'ms) '.
+			$this->file.' [update] '.json_encode($this->document));
+		if (isset($this->trigger['afterupdate']))
+			\Base::instance()->call($this->trigger['afterupdate'],
+				[$this,['_id'=>$this->id]]);
+		return $this->document;
+	}
+
+	/**
+	*	Delete current record
+	*	@return bool
+	*	@param $filter array
+	*	@param $quick bool
+	**/
+	function erase($filter=NULL,$quick=FALSE) {
+		$db=$this->db;
+		$now=microtime(TRUE);
+		$data=&$db->read($this->file);
+		$pkey=['_id'=>$this->id];
+		if ($filter) {
+			foreach ($this->find($filter,NULL,FALSE) as $mapper)
+				if (!$mapper->erase(null,$quick))
+					return FALSE;
+			return TRUE;
+		}
+		elseif (isset($this->id)) {
+			unset($data[$this->id]);
+			parent::erase();
+		}
+		else
+			return FALSE;
+		if (!$quick && isset($this->trigger['beforeerase']) &&
+			\Base::instance()->call($this->trigger['beforeerase'],
+				[$this,$pkey])===FALSE)
+			return FALSE;
+		$db->write($this->file,$data);
+		if ($filter) {
+			$args=isset($filter[1]) && is_array($filter[1])?
+				$filter[1]:
+				array_slice($filter,1,NULL,TRUE);
+			$args=is_array($args)?$args:[1=>$args];
+			foreach ($args as $key=>$val) {
+				$vals[]=\Base::instance()->
+					stringify(is_array($val)?$val[0]:$val);
+				$keys[]='/'.(is_numeric($key)?'\?':preg_quote($key)).'/';
+			}
+		}
+		$db->jot('('.sprintf('%.1f',1e3*(microtime(TRUE)-$now)).'ms) '.
+			$this->file.' [erase] '.
+			($filter?preg_replace($keys,$vals,$filter[0],1):''));
+		if (!$quick && isset($this->trigger['aftererase']))
+			\Base::instance()->call($this->trigger['aftererase'],
+				[$this,$pkey]);
+		return TRUE;
+	}
+
+	/**
+	*	Reset cursor
+	*	@return NULL
+	**/
+	function reset() {
+		$this->id=NULL;
+		$this->document=[];
+		parent::reset();
+	}
+
+	/**
+	*	Hydrate mapper object using hive array variable
+	*	@return NULL
+	*	@param $var array|string
+	*	@param $func callback
+	**/
+	function copyfrom($var,$func=NULL) {
+		if (is_string($var))
+			$var=\Base::instance()->$var;
+		if ($func)
+			$var=call_user_func($func,$var);
+		foreach ($var as $key=>$val)
+			$this->set($key,$val);
+	}
+
+	/**
+	*	Populate hive array variable with mapper fields
+	*	@return NULL
+	*	@param $key string
+	**/
+	function copyto($key) {
+		$var=&\Base::instance()->ref($key);
+		foreach ($this->document as $key=>$field)
+			$var[$key]=$field;
+	}
+
+	/**
+	*	Return field names
+	*	@return array
+	**/
+	function fields() {
+		return array_keys($this->document);
+	}
+
+	/**
+	*	Retrieve external iterator for fields
+	*	@return object
+	**/
+	function getiterator() {
+		return new \ArrayIterator($this->cast());
+	}
+
+	/**
+	*	Instantiate class
+	*	@return void
+	*	@param $db object
+	*	@param $file string
+	**/
+	function __construct(\DB\Jig $db,$file) {
+		$this->db=$db;
+		$this->file=$file;
+		$this->reset();
+	}
+
+}

+ 190 - 0
lib/f3/db/jig/session.php

@@ -0,0 +1,190 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB\Jig;
+
+//! Jig-managed session handler
+class Session extends Mapper {
+
+	protected
+		//! Session ID
+		$sid,
+		//! Anti-CSRF token
+		$_csrf,
+		//! User agent
+		$_agent,
+		//! IP,
+		$_ip,
+		//! Suspect callback
+		$onsuspect;
+
+	/**
+	*	Open session
+	*	@return TRUE
+	*	@param $path string
+	*	@param $name string
+	**/
+	function open($path,$name) {
+		return TRUE;
+	}
+
+	/**
+	*	Close session
+	*	@return TRUE
+	**/
+	function close() {
+		$this->reset();
+		$this->sid=NULL;
+		return TRUE;
+	}
+
+	/**
+	*	Return session data in serialized format
+	*	@return string
+	*	@param $id string
+	**/
+	function read($id) {
+		$this->load(['@session_id=?',$this->sid=$id]);
+		if ($this->dry())
+			return '';
+		if ($this->get('ip')!=$this->_ip || $this->get('agent')!=$this->_agent) {
+			$fw=\Base::instance();
+			if (!isset($this->onsuspect) ||
+				$fw->call($this->onsuspect,[$this,$id])===FALSE) {
+				// NB: `session_destroy` can't be called at that stage;
+				// `session_start` not completed
+				$this->destroy($id);
+				$this->close();
+				unset($fw->{'COOKIE.'.session_name()});
+				$fw->error(403);
+			}
+		}
+		return $this->get('data');
+	}
+
+	/**
+	*	Write session data
+	*	@return TRUE
+	*	@param $id string
+	*	@param $data string
+	**/
+	function write($id,$data) {
+		$this->set('session_id',$id);
+		$this->set('data',$data);
+		$this->set('ip',$this->_ip);
+		$this->set('agent',$this->_agent);
+		$this->set('stamp',time());
+		$this->save();
+		return TRUE;
+	}
+
+	/**
+	*	Destroy session
+	*	@return TRUE
+	*	@param $id string
+	**/
+	function destroy($id) {
+		$this->erase(['@session_id=?',$id]);
+		return TRUE;
+	}
+
+	/**
+	*	Garbage collector
+	*	@return TRUE
+	*	@param $max int
+	**/
+	function cleanup($max) {
+		$this->erase(['@stamp+?<?',$max,time()]);
+		return TRUE;
+	}
+
+	/**
+	 *	Return session id (if session has started)
+	 *	@return string|NULL
+	 **/
+	function sid() {
+		return $this->sid;
+	}
+
+	/**
+	 *	Return anti-CSRF token
+	 *	@return string
+	 **/
+	function csrf() {
+		return $this->_csrf;
+	}
+
+	/**
+	 *	Return IP address
+	 *	@return string
+	 **/
+	function ip() {
+		return $this->_ip;
+	}
+
+	/**
+	*	Return Unix timestamp
+	*	@return string|FALSE
+	**/
+	function stamp() {
+		if (!$this->sid)
+			session_start();
+		return $this->dry()?FALSE:$this->get('stamp');
+	}
+
+	/**
+	*	Return HTTP user agent
+	*	@return string|FALSE
+	**/
+	function agent() {
+		return $this->_agent;
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $db \DB\Jig
+	*	@param $file string
+	*	@param $onsuspect callback
+	*	@param $key string
+	**/
+	function __construct(\DB\Jig $db,$file='sessions',$onsuspect=NULL,$key=NULL) {
+		parent::__construct($db,$file);
+		$this->onsuspect=$onsuspect;
+		session_set_save_handler(
+			[$this,'open'],
+			[$this,'close'],
+			[$this,'read'],
+			[$this,'write'],
+			[$this,'destroy'],
+			[$this,'cleanup']
+		);
+		register_shutdown_function('session_commit');
+		$fw=\Base::instance();
+		$headers=$fw->HEADERS;
+		$this->_csrf=$fw->SEED.'.'.$fw->hash(mt_rand());
+		if ($key)
+			$fw->$key=$this->_csrf;
+		$this->_agent=isset($headers['User-Agent'])?$headers['User-Agent']:'';
+		$this->_ip=$fw->IP;
+	}
+
+}

+ 145 - 0
lib/f3/db/mongo.php

@@ -0,0 +1,145 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB;
+
+//! MongoDB wrapper
+class Mongo {
+
+	//@{
+	const
+		E_Profiler='MongoDB profiler is disabled';
+	//@}
+
+	protected
+		//! UUID
+		$uuid,
+		//! Data source name
+		$dsn,
+		//! MongoDB object
+		$db,
+		//! Legacy flag
+		$legacy,
+		//! MongoDB log
+		$log;
+
+	/**
+	*	Return data source name
+	*	@return string
+	**/
+	function dsn() {
+		return $this->dsn;
+	}
+
+	/**
+	*	Return UUID
+	*	@return string
+	**/
+	function uuid() {
+		return $this->uuid;
+	}
+
+	/**
+	*	Return MongoDB profiler results (or disable logging)
+	*	@param $flag bool
+	*	@return string
+	**/
+	function log($flag=TRUE) {
+		if ($flag) {
+			$cursor=$this->db->selectcollection('system.profile')->find();
+			foreach (iterator_to_array($cursor) as $frame)
+				if (!preg_match('/\.system\..+$/',$frame['ns']))
+					$this->log.=date('r',$this->legacy() ?
+						$frame['ts']->sec : (round((string)$frame['ts'])/1000)).
+						' ('.sprintf('%.1f',$frame['millis']).'ms) '.
+						$frame['ns'].' ['.$frame['op'].'] '.
+						(empty($frame['query'])?
+							'':json_encode($frame['query'])).
+						(empty($frame['command'])?
+							'':json_encode($frame['command'])).
+						PHP_EOL;
+		} else {
+			$this->log=FALSE;
+			if ($this->legacy)
+				$this->db->setprofilinglevel(-1);
+			else
+				$this->db->command(['profile'=>-1]);
+		}
+		return $this->log;
+	}
+
+	/**
+	*	Intercept native call to re-enable profiler
+	*	@return int
+	**/
+	function drop() {
+		$out=$this->db->drop();
+		if ($this->log!==FALSE) {
+			if ($this->legacy)
+				$this->db->setprofilinglevel(2);
+			else
+				$this->db->command(['profile'=>2]);
+		}
+		return $out;
+	}
+
+	/**
+	*	Redirect call to MongoDB object
+	*	@return mixed
+	*	@param $func string
+	*	@param $args array
+	**/
+	function __call($func,array $args) {
+		return call_user_func_array([$this->db,$func],$args);
+	}
+
+	/**
+	*	Return TRUE if legacy driver is loaded
+	*	@return bool
+	**/
+	function legacy() {
+		return $this->legacy;
+	}
+
+	//! Prohibit cloning
+	private function __clone() {
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $dsn string
+	*	@param $dbname string
+	*	@param $options array
+	**/
+	function __construct($dsn,$dbname,array $options=NULL) {
+		$this->uuid=\Base::instance()->hash($this->dsn=$dsn);
+		if ($this->legacy=class_exists('\MongoClient')) {
+			$this->db=new \MongoDB(new \MongoClient($dsn,$options?:[]),$dbname);
+			$this->db->setprofilinglevel(2);
+		}
+		else {
+			$this->db=(new \MongoDB\Client($dsn,$options?:[]))->$dbname;
+			$this->db->command(['profile'=>2]);
+		}
+	}
+
+}

+ 399 - 0
lib/f3/db/mongo/mapper.php

@@ -0,0 +1,399 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB\Mongo;
+
+//! MongoDB mapper
+class Mapper extends \DB\Cursor {
+
+	protected
+		//! MongoDB wrapper
+		$db,
+		//! Legacy flag
+		$legacy,
+		//! Mongo collection
+		$collection,
+		//! Mongo document
+		$document=[],
+		//! Mongo cursor
+		$cursor,
+		//! Defined fields
+		$fields;
+
+	/**
+	*	Return database type
+	*	@return string
+	**/
+	function dbtype() {
+		return 'Mongo';
+	}
+
+	/**
+	*	Return TRUE if field is defined
+	*	@return bool
+	*	@param $key string
+	**/
+	function exists($key) {
+		return array_key_exists($key,$this->document);
+	}
+
+	/**
+	*	Assign value to field
+	*	@return scalar|FALSE
+	*	@param $key string
+	*	@param $val scalar
+	**/
+	function set($key,$val) {
+		return $this->document[$key]=$val;
+	}
+
+	/**
+	*	Retrieve value of field
+	*	@return scalar|FALSE
+	*	@param $key string
+	**/
+	function &get($key) {
+		if ($this->exists($key))
+			return $this->document[$key];
+		user_error(sprintf(self::E_Field,$key),E_USER_ERROR);
+	}
+
+	/**
+	*	Delete field
+	*	@return NULL
+	*	@param $key string
+	**/
+	function clear($key) {
+		unset($this->document[$key]);
+	}
+
+	/**
+	*	Convert array to mapper object
+	*	@return static
+	*	@param $row array
+	**/
+	protected function factory($row) {
+		$mapper=clone($this);
+		$mapper->reset();
+		foreach ($row as $key=>$val)
+			$mapper->document[$key]=$val;
+		$mapper->query=[clone($mapper)];
+		if (isset($mapper->trigger['load']))
+			\Base::instance()->call($mapper->trigger['load'],$mapper);
+		return $mapper;
+	}
+
+	/**
+	*	Return fields of mapper object as an associative array
+	*	@return array
+	*	@param $obj object
+	**/
+	function cast($obj=NULL) {
+		if (!$obj)
+			$obj=$this;
+		return $obj->document;
+	}
+
+	/**
+	*	Build query and execute
+	*	@return static[]
+	*	@param $fields string
+	*	@param $filter array
+	*	@param $options array
+	*	@param $ttl int
+	**/
+	function select($fields=NULL,$filter=NULL,array $options=NULL,$ttl=0) {
+		if (!$options)
+			$options=[];
+		$options+=[
+			'group'=>NULL,
+			'order'=>NULL,
+			'limit'=>0,
+			'offset'=>0
+		];
+		$fw=\Base::instance();
+		$cache=\Cache::instance();
+		if (!($cached=$cache->exists($hash=$fw->hash($this->db->dsn().
+			$fw->stringify([$fields,$filter,$options])).'.mongo',
+			$result)) || !$ttl || $cached[0]+$ttl<microtime(TRUE)) {
+			if ($options['group']) {
+				$grp=$this->collection->group(
+					$options['group']['keys'],
+					$options['group']['initial'],
+					$options['group']['reduce'],
+					[
+						'condition'=>$filter,
+						'finalize'=>$options['group']['finalize']
+					]
+				);
+				$tmp=$this->db->selectcollection(
+					$fw->HOST.'.'.$fw->BASE.'.'.
+					uniqid(NULL,TRUE).'.tmp'
+				);
+				$tmp->batchinsert($grp['retval'],['w'=>1]);
+				$filter=[];
+				$collection=$tmp;
+			}
+			else {
+				$filter=$filter?:[];
+				$collection=$this->collection;
+			}
+			if ($this->legacy) {
+				$this->cursor=$collection->find($filter,$fields?:[]);
+				if ($options['order'])
+					$this->cursor=$this->cursor->sort($options['order']);
+				if ($options['limit'])
+					$this->cursor=$this->cursor->limit($options['limit']);
+				if ($options['offset'])
+					$this->cursor=$this->cursor->skip($options['offset']);
+				$result=[];
+				while ($this->cursor->hasnext())
+					$result[]=$this->cursor->getnext();
+			}
+			else {
+				$this->cursor=$collection->find($filter,[
+					'sort'=>$options['order'],
+					'limit'=>$options['limit'],
+					'skip'=>$options['offset']
+				]);
+				$result=$this->cursor->toarray();
+			}
+			if ($options['group'])
+				$tmp->drop();
+			if ($fw->CACHE && $ttl)
+				// Save to cache backend
+				$cache->set($hash,$result,$ttl);
+		}
+		$out=[];
+		foreach ($result as $doc)
+			$out[]=$this->factory($doc);
+		return $out;
+	}
+
+	/**
+	*	Return records that match criteria
+	*	@return static[]
+	*	@param $filter array
+	*	@param $options array
+	*	@param $ttl int
+	**/
+	function find($filter=NULL,array $options=NULL,$ttl=0) {
+		if (!$options)
+			$options=[];
+		$options+=[
+			'group'=>NULL,
+			'order'=>NULL,
+			'limit'=>0,
+			'offset'=>0
+		];
+		return $this->select($this->fields,$filter,$options,$ttl);
+	}
+
+	/**
+	*	Count records that match criteria
+	*	@return int
+	*	@param $filter array
+	*	@param $options array
+	*	@param $ttl int
+	**/
+	function count($filter=NULL,array $options=NULL,$ttl=0) {
+		$fw=\Base::instance();
+		$cache=\Cache::instance();
+		if (!($cached=$cache->exists($hash=$fw->hash($fw->stringify(
+			[$filter])).'.mongo',$result)) || !$ttl ||
+			$cached[0]+$ttl<microtime(TRUE)) {
+			$result=$this->collection->count($filter?:[]);
+			if ($fw->CACHE && $ttl)
+				// Save to cache backend
+				$cache->set($hash,$result,$ttl);
+		}
+		return $result;
+	}
+
+	/**
+	*	Return record at specified offset using criteria of previous
+	*	load() call and make it active
+	*	@return array
+	*	@param $ofs int
+	**/
+	function skip($ofs=1) {
+		$this->document=($out=parent::skip($ofs))?$out->document:[];
+		if ($this->document && isset($this->trigger['load']))
+			\Base::instance()->call($this->trigger['load'],$this);
+		return $out;
+	}
+
+	/**
+	*	Insert new record
+	*	@return array
+	**/
+	function insert() {
+		if (isset($this->document['_id']))
+			return $this->update();
+		if (isset($this->trigger['beforeinsert']) &&
+			\Base::instance()->call($this->trigger['beforeinsert'],
+				[$this,['_id'=>$this->document['_id']]])===FALSE)
+			return $this->document;
+		if ($this->legacy) {
+			$this->collection->insert($this->document);
+			$pkey=['_id'=>$this->document['_id']];
+		}
+		else {
+			$result=$this->collection->insertone($this->document);
+			$pkey=['_id'=>$result->getinsertedid()];
+		}
+		if (isset($this->trigger['afterinsert']))
+			\Base::instance()->call($this->trigger['afterinsert'],
+				[$this,$pkey]);
+		$this->load($pkey);
+		return $this->document;
+	}
+
+	/**
+	*	Update current record
+	*	@return array
+	**/
+	function update() {
+		$pkey=['_id'=>$this->document['_id']];
+		if (isset($this->trigger['beforeupdate']) &&
+			\Base::instance()->call($this->trigger['beforeupdate'],
+				[$this,$pkey])===FALSE)
+			return $this->document;
+		$upsert=['upsert'=>TRUE];
+		if ($this->legacy)
+			$this->collection->update($pkey,$this->document,$upsert);
+		else
+			$this->collection->replaceone($pkey,$this->document,$upsert);
+		if (isset($this->trigger['afterupdate']))
+			\Base::instance()->call($this->trigger['afterupdate'],
+				[$this,$pkey]);
+		return $this->document;
+	}
+
+	/**
+	*	Delete current record
+	*	@return bool
+	*	@param $quick bool
+	*	@param $filter array
+	**/
+	function erase($filter=NULL,$quick=TRUE) {
+		if ($filter) {
+			if (!$quick) {
+				foreach ($this->find($filter) as $mapper)
+					if (!$mapper->erase())
+						return FALSE;
+				return TRUE;
+			}
+			return $this->legacy?
+				$this->collection->remove($filter):
+				$this->collection->deletemany($filter);
+		}
+		$pkey=['_id'=>$this->document['_id']];
+		if (isset($this->trigger['beforeerase']) &&
+			\Base::instance()->call($this->trigger['beforeerase'],
+				[$this,$pkey])===FALSE)
+			return FALSE;
+		$result=$this->legacy?
+			$this->collection->remove(['_id'=>$this->document['_id']]):
+			$this->collection->deleteone(['_id'=>$this->document['_id']]);
+		parent::erase();
+		if (isset($this->trigger['aftererase']))
+			\Base::instance()->call($this->trigger['aftererase'],
+				[$this,$pkey]);
+		return $result;
+	}
+
+	/**
+	*	Reset cursor
+	*	@return NULL
+	**/
+	function reset() {
+		$this->document=[];
+		parent::reset();
+	}
+
+	/**
+	*	Hydrate mapper object using hive array variable
+	*	@return NULL
+	*	@param $var array|string
+	*	@param $func callback
+	**/
+	function copyfrom($var,$func=NULL) {
+		if (is_string($var))
+			$var=\Base::instance()->$var;
+		if ($func)
+			$var=call_user_func($func,$var);
+		foreach ($var as $key=>$val)
+			$this->set($key,$val);
+	}
+
+	/**
+	*	Populate hive array variable with mapper fields
+	*	@return NULL
+	*	@param $key string
+	**/
+	function copyto($key) {
+		$var=&\Base::instance()->ref($key);
+		foreach ($this->document as $key=>$field)
+			$var[$key]=$field;
+	}
+
+	/**
+	*	Return field names
+	*	@return array
+	**/
+	function fields() {
+		return array_keys($this->document);
+	}
+
+	/**
+	*	Return the cursor from last query
+	*	@return object|NULL
+	**/
+	function cursor() {
+		return $this->cursor;
+	}
+
+	/**
+	*	Retrieve external iterator for fields
+	*	@return object
+	**/
+	function getiterator() {
+		return new \ArrayIterator($this->cast());
+	}
+
+	/**
+	*	Instantiate class
+	*	@return void
+	*	@param $db object
+	*	@param $collection string
+	*	@param $fields array
+	**/
+	function __construct(\DB\Mongo $db,$collection,$fields=NULL) {
+		$this->db=$db;
+		$this->legacy=$db->legacy();
+		$this->collection=$db->selectcollection($collection);
+		$this->fields=$fields;
+		$this->reset();
+	}
+
+}

+ 190 - 0
lib/f3/db/mongo/session.php

@@ -0,0 +1,190 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB\Mongo;
+
+//! MongoDB-managed session handler
+class Session extends Mapper {
+
+	protected
+		//! Session ID
+		$sid,
+		//! Anti-CSRF token
+		$_csrf,
+		//! User agent
+		$_agent,
+		//! IP,
+		$_ip,
+		//! Suspect callback
+		$onsuspect;
+
+	/**
+	*	Open session
+	*	@return TRUE
+	*	@param $path string
+	*	@param $name string
+	**/
+	function open($path,$name) {
+		return TRUE;
+	}
+
+	/**
+	*	Close session
+	*	@return TRUE
+	**/
+	function close() {
+		$this->reset();
+		$this->sid=NULL;
+		return TRUE;
+	}
+
+	/**
+	*	Return session data in serialized format
+	*	@return string
+	*	@param $id string
+	**/
+	function read($id) {
+		$this->load(['session_id'=>$this->sid=$id]);
+		if ($this->dry())
+			return '';
+		if ($this->get('ip')!=$this->_ip || $this->get('agent')!=$this->_agent) {
+			$fw=\Base::instance();
+			if (!isset($this->onsuspect) ||
+				$fw->call($this->onsuspect,[$this,$id])===FALSE) {
+				// NB: `session_destroy` can't be called at that stage;
+				// `session_start` not completed
+				$this->destroy($id);
+				$this->close();
+				unset($fw->{'COOKIE.'.session_name()});
+				$fw->error(403);
+			}
+		}
+		return $this->get('data');
+	}
+
+	/**
+	*	Write session data
+	*	@return TRUE
+	*	@param $id string
+	*	@param $data string
+	**/
+	function write($id,$data) {
+		$this->set('session_id',$id);
+		$this->set('data',$data);
+		$this->set('ip',$this->_ip);
+		$this->set('agent',$this->_agent);
+		$this->set('stamp',time());
+		$this->save();
+		return TRUE;
+	}
+
+	/**
+	*	Destroy session
+	*	@return TRUE
+	*	@param $id string
+	**/
+	function destroy($id) {
+		$this->erase(['session_id'=>$id]);
+		return TRUE;
+	}
+
+	/**
+	*	Garbage collector
+	*	@return TRUE
+	*	@param $max int
+	**/
+	function cleanup($max) {
+		$this->erase(['$where'=>'this.stamp+'.$max.'<'.time()]);
+		return TRUE;
+	}
+
+	/**
+	 *	Return session id (if session has started)
+	 *	@return string|NULL
+	 **/
+	function sid() {
+		return $this->sid;
+	}
+
+	/**
+	 *	Return anti-CSRF token
+	 *	@return string
+	 **/
+	function csrf() {
+		return $this->_csrf;
+	}
+
+	/**
+	 *	Return IP address
+	 *	@return string
+	 **/
+	function ip() {
+		return $this->_ip;
+	}
+
+	/**
+	 *	Return Unix timestamp
+	 *	@return string|FALSE
+	 **/
+	function stamp() {
+		if (!$this->sid)
+			session_start();
+		return $this->dry()?FALSE:$this->get('stamp');
+	}
+
+	/**
+	 *	Return HTTP user agent
+	 *	@return string
+	 **/
+	function agent() {
+		return $this->_agent;
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $db \DB\Mongo
+	*	@param $table string
+	*	@param $onsuspect callback
+	*	@param $key string
+	**/
+	function __construct(\DB\Mongo $db,$table='sessions',$onsuspect=NULL,$key=NULL) {
+		parent::__construct($db,$table);
+		$this->onsuspect=$onsuspect;
+		session_set_save_handler(
+			[$this,'open'],
+			[$this,'close'],
+			[$this,'read'],
+			[$this,'write'],
+			[$this,'destroy'],
+			[$this,'cleanup']
+		);
+		register_shutdown_function('session_commit');
+		$fw=\Base::instance();
+		$headers=$fw->HEADERS;
+		$this->_csrf=$fw->SEED.'.'.$fw->hash(mt_rand());
+		if ($key)
+			$fw->$key=$this->_csrf;
+		$this->_agent=isset($headers['User-Agent'])?$headers['User-Agent']:'';
+		$this->_ip=$fw->IP;
+	}
+
+}

+ 523 - 0
lib/f3/db/sql.php

@@ -0,0 +1,523 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB;
+
+//! PDO wrapper
+class SQL {
+
+	//@{ Error messages
+	const
+		E_PKey='Table %s does not have a primary key';
+	//@}
+
+	const
+		PARAM_FLOAT='float';
+
+	protected
+		//! UUID
+		$uuid,
+		//! Raw PDO
+		$pdo,
+		//! Data source name
+		$dsn,
+		//! Database engine
+		$engine,
+		//! Database name
+		$dbname,
+		//! Transaction flag
+		$trans=FALSE,
+		//! Number of rows affected by query
+		$rows=0,
+		//! SQL log
+		$log;
+
+	/**
+	*	Begin SQL transaction
+	*	@return bool
+	**/
+	function begin() {
+		$out=$this->pdo->begintransaction();
+		$this->trans=TRUE;
+		return $out;
+	}
+
+	/**
+	*	Rollback SQL transaction
+	*	@return bool
+	**/
+	function rollback() {
+		$out=$this->pdo->rollback();
+		$this->trans=FALSE;
+		return $out;
+	}
+
+	/**
+	*	Commit SQL transaction
+	*	@return bool
+	**/
+	function commit() {
+		$out=$this->pdo->commit();
+		$this->trans=FALSE;
+		return $out;
+	}
+
+	/**
+	*	Return transaction flag
+	*	@return bool
+	**/
+	function trans() {
+		return $this->trans;
+	}
+
+	/**
+	*	Map data type of argument to a PDO constant
+	*	@return int
+	*	@param $val scalar
+	**/
+	function type($val) {
+		switch (gettype($val)) {
+			case 'NULL':
+				return \PDO::PARAM_NULL;
+			case 'boolean':
+				return \PDO::PARAM_BOOL;
+			case 'integer':
+				return \PDO::PARAM_INT;
+			case 'resource':
+				return \PDO::PARAM_LOB;
+			case 'float':
+				return self::PARAM_FLOAT;
+			default:
+				return \PDO::PARAM_STR;
+		}
+	}
+
+	/**
+	*	Cast value to PHP type
+	*	@return mixed
+	*	@param $type string
+	*	@param $val mixed
+	**/
+	function value($type,$val) {
+		switch ($type) {
+			case self::PARAM_FLOAT:
+				if (!is_string($val))
+					$val=str_replace(',','.',$val);
+				return $val;
+			case \PDO::PARAM_NULL:
+				return NULL;
+			case \PDO::PARAM_INT:
+				return (int)$val;
+			case \PDO::PARAM_BOOL:
+				return (bool)$val;
+			case \PDO::PARAM_STR:
+				return (string)$val;
+			case \PDO::PARAM_LOB:
+				return (binary)$val;
+		}
+	}
+
+	/**
+	*	Execute SQL statement(s)
+	*	@return array|int|FALSE
+	*	@param $cmds string|array
+	*	@param $args string|array
+	*	@param $ttl int|array
+	*	@param $log bool
+	*	@param $stamp bool
+	**/
+	function exec($cmds,$args=NULL,$ttl=0,$log=TRUE,$stamp=FALSE) {
+		$tag='';
+		if (is_array($ttl))
+			list($ttl,$tag)=$ttl;
+		$auto=FALSE;
+		if (is_null($args))
+			$args=[];
+		elseif (is_scalar($args))
+			$args=[1=>$args];
+		if (is_array($cmds)) {
+			if (count($args)<($count=count($cmds)))
+				// Apply arguments to SQL commands
+				$args=array_fill(0,$count,$args);
+			if (!$this->trans) {
+				$this->begin();
+				$auto=TRUE;
+			}
+		}
+		else {
+			$count=1;
+			$cmds=[$cmds];
+			$args=[$args];
+		}
+		if ($this->log===FALSE)
+			$log=FALSE;
+		$fw=\Base::instance();
+		$cache=\Cache::instance();
+		$result=FALSE;
+		for ($i=0;$i<$count;$i++) {
+			$cmd=$cmds[$i];
+			$arg=$args[$i];
+			// ensure 1-based arguments
+			if (array_key_exists(0,$arg)) {
+				array_unshift($arg,'');
+				unset($arg[0]);
+			}
+			if (!preg_replace('/(^\s+|[\s;]+$)/','',$cmd))
+				continue;
+			$now=microtime(TRUE);
+			$keys=$vals=[];
+			if ($fw->CACHE && $ttl && ($cached=$cache->exists(
+				$hash=$fw->hash($this->dsn.$cmd.
+				$fw->stringify($arg)).($tag?'.'.$tag:'').'.sql',$result)) &&
+				$cached[0]+$ttl>microtime(TRUE)) {
+				foreach ($arg as $key=>$val) {
+					$vals[]=$fw->stringify(is_array($val)?$val[0]:$val);
+					$keys[]='/'.preg_quote(is_numeric($key)?chr(0).'?':$key).
+						'/';
+				}
+				if ($log)
+					$this->log.=($stamp?(date('r').' '):'').'('.
+						sprintf('%.1f',1e3*(microtime(TRUE)-$now)).'ms) '.
+						'[CACHED] '.
+						preg_replace($keys,$vals,
+							str_replace('?',chr(0).'?',$cmd),1).PHP_EOL;
+			}
+			elseif (is_object($query=$this->pdo->prepare($cmd))) {
+				foreach ($arg as $key=>$val) {
+					if (is_array($val)) {
+						// User-specified data type
+						$query->bindvalue($key,$val[0],
+							$val[1]==self::PARAM_FLOAT?\PDO::PARAM_STR:$val[1]);
+						$vals[]=$fw->stringify($this->value($val[1],$val[0]));
+					}
+					else {
+						// Convert to PDO data type
+						$query->bindvalue($key,$val,
+							($type=$this->type($val))==self::PARAM_FLOAT?
+								\PDO::PARAM_STR:$type);
+						$vals[]=$fw->stringify($this->value($type,$val));
+					}
+					$keys[]='/'.preg_quote(is_numeric($key)?chr(0).'?':$key).
+						'/';
+				}
+				if ($log)
+					$this->log.=($stamp?(date('r').' '):'').' (-0ms) '.
+						preg_replace($keys,$vals,
+							str_replace('?',chr(0).'?',$cmd),1).PHP_EOL;
+				$query->execute();
+				if ($log)
+					$this->log=str_replace('(-0ms)',
+						'('.sprintf('%.1f',1e3*(microtime(TRUE)-$now)).'ms)',
+						$this->log);
+				if (($error=$query->errorinfo()) && $error[0]!=\PDO::ERR_NONE) {
+					// Statement-level error occurred
+					if ($this->trans)
+						$this->rollback();
+					user_error('PDOStatement: '.$error[2],E_USER_ERROR);
+				}
+				if (preg_match('/(?:^[\s\(]*'.
+					'(?:EXPLAIN|SELECT|PRAGMA|SHOW)|RETURNING)\b/is',$cmd) ||
+					(preg_match('/^\s*(?:CALL|EXEC)\b/is',$cmd) &&
+						$query->columnCount())) {
+					$result=$query->fetchall(\PDO::FETCH_ASSOC);
+					// Work around SQLite quote bug
+					if (preg_match('/sqlite2?/',$this->engine))
+						foreach ($result as $pos=>$rec) {
+							unset($result[$pos]);
+							$result[$pos]=[];
+							foreach ($rec as $key=>$val)
+								$result[$pos][trim($key,'\'"[]`')]=$val;
+						}
+					$this->rows=count($result);
+					if ($fw->CACHE && $ttl)
+						// Save to cache backend
+						$cache->set($hash,$result,$ttl);
+				}
+				else
+					$this->rows=$result=$query->rowcount();
+				$query->closecursor();
+				unset($query);
+			}
+			elseif (($error=$this->errorinfo()) && $error[0]!=\PDO::ERR_NONE) {
+				// PDO-level error occurred
+				if ($this->trans)
+					$this->rollback();
+				user_error('PDO: '.$error[2],E_USER_ERROR);
+			}
+
+		}
+		if ($this->trans && $auto)
+			$this->commit();
+		return $result;
+	}
+
+	/**
+	*	Return number of rows affected by last query
+	*	@return int
+	**/
+	function count() {
+		return $this->rows;
+	}
+
+	/**
+	*	Return SQL profiler results (or disable logging)
+	*	@return string
+	*	@param $flag bool
+	**/
+	function log($flag=TRUE) {
+		if ($flag)
+			return $this->log;
+		$this->log=FALSE;
+	}
+
+	/**
+	*	Return TRUE if table exists
+	*	@return bool
+	*	@param $table string
+	**/
+	function exists($table) {
+		$mode=$this->pdo->getAttribute(\PDO::ATTR_ERRMODE);
+		$this->pdo->setAttribute(\PDO::ATTR_ERRMODE,\PDO::ERRMODE_SILENT);
+		$out=$this->pdo->
+			query('SELECT 1 FROM '.$this->quotekey($table).' LIMIT 1');
+		$this->pdo->setAttribute(\PDO::ATTR_ERRMODE,$mode);
+		return is_object($out);
+	}
+
+	/**
+	*	Retrieve schema of SQL table
+	*	@return array|FALSE
+	*	@param $table string
+	*	@param $fields array|string
+	*	@param $ttl int|array
+	**/
+	function schema($table,$fields=NULL,$ttl=0) {
+		$fw=\Base::instance();
+		$cache=\Cache::instance();
+		if ($fw->CACHE && $ttl &&
+			($cached=$cache->exists(
+				$hash=$fw->hash($this->dsn.$table).'.schema',$result)) &&
+			$cached[0]+$ttl>microtime(TRUE))
+			return $result;
+		if (strpos($table,'.'))
+			list($schema,$table)=explode('.',$table);
+		// Supported engines
+		$cmd=[
+			'sqlite2?'=>[
+				'PRAGMA table_info(`'.$table.'`)',
+				'name','type','dflt_value','notnull',0,'pk',TRUE],
+			'mysql'=>[
+				'SHOW columns FROM `'.$this->dbname.'`.`'.$table.'`',
+				'Field','Type','Default','Null','YES','Key','PRI'],
+			'mssql|sqlsrv|sybase|dblib|pgsql|odbc'=>[
+				'SELECT '.
+					'C.COLUMN_NAME AS field,'.
+					'C.DATA_TYPE AS type,'.
+					'C.COLUMN_DEFAULT AS defval,'.
+					'C.IS_NULLABLE AS nullable,'.
+					'T.CONSTRAINT_TYPE AS pkey '.
+				'FROM INFORMATION_SCHEMA.COLUMNS AS C '.
+				'LEFT OUTER JOIN '.
+					'INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS K '.
+					'ON '.
+						'C.TABLE_NAME=K.TABLE_NAME AND '.
+						'C.COLUMN_NAME=K.COLUMN_NAME AND '.
+						'C.TABLE_SCHEMA=K.TABLE_SCHEMA '.
+						($this->dbname?
+							('AND C.TABLE_CATALOG=K.TABLE_CATALOG '):'').
+				'LEFT OUTER JOIN '.
+					'INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS T ON '.
+						'K.TABLE_NAME=T.TABLE_NAME AND '.
+						'K.CONSTRAINT_NAME=T.CONSTRAINT_NAME AND '.
+						'K.TABLE_SCHEMA=T.TABLE_SCHEMA '.
+						($this->dbname?
+							('AND K.TABLE_CATALOG=T.TABLE_CATALOG '):'').
+				'WHERE '.
+					'C.TABLE_NAME='.$this->quote($table).
+					($this->dbname?
+						(' AND C.TABLE_CATALOG='.
+							$this->quote($this->dbname)):''),
+				'field','type','defval','nullable','YES','pkey','PRIMARY KEY'],
+			'oci'=>[
+				'SELECT c.column_name AS field, '.
+					'c.data_type AS type, '.
+					'c.data_default AS defval, '.
+					'c.nullable AS nullable, '.
+					'(SELECT t.constraint_type '.
+						'FROM all_cons_columns acc '.
+						'LEFT OUTER JOIN all_constraints t '.
+						'ON acc.constraint_name=t.constraint_name '.
+						'WHERE acc.table_name='.$this->quote($table).' '.
+						'AND acc.column_name=c.column_name '.
+						'AND constraint_type='.$this->quote('P').') AS pkey '.
+				'FROM all_tab_cols c '.
+				'WHERE c.table_name='.$this->quote($table),
+				'FIELD','TYPE','DEFVAL','NULLABLE','Y','PKEY','P']
+		];
+		if (is_string($fields))
+			$fields=\Base::instance()->split($fields);
+		$conv=[
+			'int\b|integer'=>\PDO::PARAM_INT,
+			'bool'=>\PDO::PARAM_BOOL,
+			'blob|bytea|image|binary'=>\PDO::PARAM_LOB,
+			'float|real|double|decimal|numeric'=>self::PARAM_FLOAT,
+			'.+'=>\PDO::PARAM_STR
+		];
+		foreach ($cmd as $key=>$val)
+			if (preg_match('/'.$key.'/',$this->engine)) {
+				$rows=[];
+				foreach ($this->exec($val[0],NULL) as $row)
+					if (!$fields || in_array($row[$val[1]],$fields)) {
+						foreach ($conv as $regex=>$type)
+							if (preg_match('/'.$regex.'/i',$row[$val[2]]))
+								break;
+						$rows[$row[$val[1]]]=[
+							'type'=>$row[$val[2]],
+							'pdo_type'=>$type,
+							'default'=>is_string($row[$val[3]])?
+								preg_replace('/^\s*([\'"])(.*)\1\s*/','\2',
+								$row[$val[3]]):$row[$val[3]],
+							'nullable'=>$row[$val[4]]==$val[5],
+							'pkey'=>$row[$val[6]]==$val[7]
+						];
+					}
+				if ($fw->CACHE && $ttl)
+					// Save to cache backend
+					$cache->set($hash,$rows,$ttl);
+				return $rows;
+			}
+		user_error(sprintf(self::E_PKey,$table),E_USER_ERROR);
+		return FALSE;
+	}
+
+	/**
+	*	Quote string
+	*	@return string
+	*	@param $val mixed
+	*	@param $type int
+	**/
+	function quote($val,$type=\PDO::PARAM_STR) {
+		return $this->engine=='odbc'?
+			(is_string($val)?
+				\Base::instance()->stringify(str_replace('\'','\'\'',$val)):
+				$val):
+			$this->pdo->quote($val,$type);
+	}
+
+	/**
+	*	Return UUID
+	*	@return string
+	**/
+	function uuid() {
+		return $this->uuid;
+	}
+
+	/**
+	*	Return parent object
+	*	@return \PDO
+	**/
+	function pdo() {
+		return $this->pdo;
+	}
+
+	/**
+	*	Return database engine
+	*	@return string
+	**/
+	function driver() {
+		return $this->engine;
+	}
+
+	/**
+	*	Return server version
+	*	@return string
+	**/
+	function version() {
+		return $this->pdo->getattribute(\PDO::ATTR_SERVER_VERSION);
+	}
+
+	/**
+	*	Return database name
+	*	@return string
+	**/
+	function name() {
+		return $this->dbname;
+	}
+
+	/**
+	*	Return quoted identifier name
+	*	@return string
+	*	@param $key
+	*	@param bool $split
+	 **/
+	function quotekey($key, $split=TRUE) {
+		$delims=[
+			'sqlite2?|mysql'=>'``',
+			'pgsql|oci'=>'""',
+			'mssql|sqlsrv|odbc|sybase|dblib'=>'[]'
+		];
+		$use='';
+		foreach ($delims as $engine=>$delim)
+			if (preg_match('/'.$engine.'/',$this->engine)) {
+				$use=$delim;
+				break;
+			}
+		return $use[0].($split ? implode($use[1].'.'.$use[0],explode('.',$key))
+			: $key).$use[1];
+	}
+
+	/**
+	*	Redirect call to PDO object
+	*	@return mixed
+	*	@param $func string
+	*	@param $args array
+	**/
+	function __call($func,array $args) {
+		return call_user_func_array([$this->pdo,$func],$args);
+	}
+
+	//! Prohibit cloning
+	private function __clone() {
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $dsn string
+	*	@param $user string
+	*	@param $pw string
+	*	@param $options array
+	**/
+	function __construct($dsn,$user=NULL,$pw=NULL,array $options=NULL) {
+		$fw=\Base::instance();
+		$this->uuid=$fw->hash($this->dsn=$dsn);
+		if (preg_match('/^.+?(?:dbname|database)=(.+?)(?=;|$)/is',$dsn,$parts))
+			$this->dbname=$parts[1];
+		if (!$options)
+			$options=[];
+		if (isset($parts[0]) && strstr($parts[0],':',TRUE)=='mysql')
+			$options+=[\PDO::MYSQL_ATTR_INIT_COMMAND=>'SET NAMES '.
+				strtolower(str_replace('-','',$fw->ENCODING)).';'];
+		$this->pdo=new \PDO($dsn,$user,$pw,$options);
+		$this->engine=$this->pdo->getattribute(\PDO::ATTR_DRIVER_NAME);
+	}
+
+}

+ 672 - 0
lib/f3/db/sql/mapper.php

@@ -0,0 +1,672 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB\SQL;
+
+//! SQL data mapper
+class Mapper extends \DB\Cursor {
+
+	protected
+		//! PDO wrapper
+		$db,
+		//! Database engine
+		$engine,
+		//! SQL table
+		$source,
+		//! SQL table (quoted)
+		$table,
+		//! Last insert ID
+		$_id,
+		//! Defined fields
+		$fields,
+		//! Adhoc fields
+		$adhoc=[],
+		//! Dynamic properties
+		$props=[];
+
+	/**
+	*	Return database type
+	*	@return string
+	**/
+	function dbtype() {
+		return 'SQL';
+	}
+
+	/**
+	*	Return mapped table
+	*	@return string
+	**/
+	function table() {
+		return $this->source;
+	}
+
+	/**
+	*	Return TRUE if any/specified field value has changed
+	*	@return bool
+	*	@param $key string
+	**/
+	function changed($key=NULL) {
+		if (isset($key))
+			return $this->fields[$key]['changed'];
+		foreach($this->fields as $key=>$field)
+			if ($field['changed'])
+				return TRUE;
+		return FALSE;
+	}
+
+	/**
+	*	Return TRUE if field is defined
+	*	@return bool
+	*	@param $key string
+	**/
+	function exists($key) {
+		return array_key_exists($key,$this->fields+$this->adhoc);
+	}
+
+	/**
+	*	Assign value to field
+	*	@return scalar
+	*	@param $key string
+	*	@param $val scalar
+	**/
+	function set($key,$val) {
+		if (array_key_exists($key,$this->fields)) {
+			$val=is_null($val) && $this->fields[$key]['nullable']?
+				NULL:$this->db->value($this->fields[$key]['pdo_type'],$val);
+			if ($this->fields[$key]['initial']!==$val ||
+				$this->fields[$key]['default']!==$val && is_null($val))
+				$this->fields[$key]['changed']=TRUE;
+			return $this->fields[$key]['value']=$val;
+		}
+		// Adjust result on existing expressions
+		if (isset($this->adhoc[$key]))
+			$this->adhoc[$key]['value']=$val;
+		elseif (is_string($val))
+			// Parenthesize expression in case it's a subquery
+			$this->adhoc[$key]=['expr'=>'('.$val.')','value'=>NULL];
+		else
+			$this->props[$key]=$val;
+		return $val;
+	}
+
+	/**
+	*	Retrieve value of field
+	*	@return scalar
+	*	@param $key string
+	**/
+	function &get($key) {
+		if ($key=='_id')
+			return $this->_id;
+		elseif (array_key_exists($key,$this->fields))
+			return $this->fields[$key]['value'];
+		elseif (array_key_exists($key,$this->adhoc))
+			return $this->adhoc[$key]['value'];
+		elseif (array_key_exists($key,$this->props))
+			return $this->props[$key];
+		user_error(sprintf(self::E_Field,$key),E_USER_ERROR);
+	}
+
+	/**
+	*	Clear value of field
+	*	@return NULL
+	*	@param $key string
+	**/
+	function clear($key) {
+		if (array_key_exists($key,$this->adhoc))
+			unset($this->adhoc[$key]);
+		else
+			unset($this->props[$key]);
+	}
+
+	/**
+	*	Invoke dynamic method
+	*	@return mixed
+	*	@param $func string
+	*	@param $args array
+	**/
+	function __call($func,$args) {
+		return call_user_func_array(
+			(array_key_exists($func,$this->props)?
+				$this->props[$func]:
+				$this->$func),$args
+		);
+	}
+
+	/**
+	*	Convert array to mapper object
+	*	@return static
+	*	@param $row array
+	**/
+	protected function factory($row) {
+		$mapper=clone($this);
+		$mapper->reset();
+		foreach ($row as $key=>$val) {
+			if (array_key_exists($key,$this->fields))
+				$var='fields';
+			elseif (array_key_exists($key,$this->adhoc))
+				$var='adhoc';
+			else
+				continue;
+			$mapper->{$var}[$key]['value']=$val;
+			$mapper->{$var}[$key]['initial']=$val;
+			if ($var=='fields' && $mapper->{$var}[$key]['pkey'])
+				$mapper->{$var}[$key]['previous']=$val;
+		}
+		$mapper->query=[clone($mapper)];
+		if (isset($mapper->trigger['load']))
+			\Base::instance()->call($mapper->trigger['load'],$mapper);
+		return $mapper;
+	}
+
+	/**
+	*	Return fields of mapper object as an associative array
+	*	@return array
+	*	@param $obj object
+	**/
+	function cast($obj=NULL) {
+		if (!$obj)
+			$obj=$this;
+		return array_map(
+			function($row) {
+				return $row['value'];
+			},
+			$obj->fields+$obj->adhoc
+		);
+	}
+
+	/**
+	*	Build query string and arguments
+	*	@return array
+	*	@param $fields string
+	*	@param $filter string|array
+	*	@param $options array
+	**/
+	function stringify($fields,$filter=NULL,array $options=NULL) {
+		if (!$options)
+			$options=[];
+		$options+=[
+			'group'=>NULL,
+			'order'=>NULL,
+			'limit'=>0,
+			'offset'=>0
+		];
+		$db=$this->db;
+		$sql='SELECT '.$fields.' FROM '.$this->table;
+		$args=[];
+		if (is_array($filter)) {
+			$args=isset($filter[1]) && is_array($filter[1])?
+				$filter[1]:
+				array_slice($filter,1,NULL,TRUE);
+			$args=is_array($args)?$args:[1=>$args];
+			list($filter)=$filter;
+		}
+		if ($filter)
+			$sql.=' WHERE '.$filter;
+		if ($options['group']) {
+			$sql.=' GROUP BY '.implode(',',array_map(
+				function($str) use($db) {
+					return preg_replace_callback(
+						'/\b(\w+[._\-\w]*)\h*(HAVING.+|$)/i',
+						function($parts) use($db) {
+							return $db->quotekey($parts[1]).
+								(isset($parts[2])?(' '.$parts[2]):'');
+						},
+						$str
+					);
+				},
+				explode(',',$options['group'])));
+		}
+		if ($options['order']) {
+			$char=substr($db->quotekey(''),0,1);// quoting char
+			$order=' ORDER BY '.(FALSE===strpos($options['order'],$char)?
+				implode(',',array_map(function($str) use($db) {
+					return preg_match('/^\h*(\w+[._\-\w]*)(?:\h+((?:ASC|DESC)[\w\h]*))?\h*$/i',
+						$str,$parts)?
+						($db->quotekey($parts[1]).
+						(isset($parts[2])?(' '.$parts[2]):'')):$str;
+				},explode(',',$options['order']))):
+				$options['order']);
+		}
+		// SQL Server fixes
+		if (preg_match('/mssql|sqlsrv|odbc/', $this->engine) &&
+			($options['limit'] || $options['offset'])) {
+			// order by pkey when no ordering option was given
+			if (!$options['order'])
+				foreach ($this->fields as $key=>$field)
+					if ($field['pkey']) {
+						$order=' ORDER BY '.$db->quotekey($key);
+						break;
+					}
+			$ofs=$options['offset']?(int)$options['offset']:0;
+			$lmt=$options['limit']?(int)$options['limit']:0;
+			if (strncmp($db->version(),'11',2)>=0) {
+				// SQL Server >= 2012
+				$sql.=$order.' OFFSET '.$ofs.' ROWS';
+				if ($lmt)
+					$sql.=' FETCH NEXT '.$lmt.' ROWS ONLY';
+			}
+			else {
+				// SQL Server 2008
+				$sql=preg_replace('/SELECT/',
+					'SELECT '.
+					($lmt>0?'TOP '.($ofs+$lmt):'').' ROW_NUMBER() '.
+					'OVER ('.$order.') AS rnum,',$sql.$order,1);
+				$sql='SELECT * FROM ('.$sql.') x WHERE rnum > '.($ofs);
+			}
+		}
+		else {
+			if (isset($order))
+				$sql.=$order;
+			if ($options['limit'])
+				$sql.=' LIMIT '.(int)$options['limit'];
+			if ($options['offset'])
+				$sql.=' OFFSET '.(int)$options['offset'];
+		}
+		return [$sql,$args];
+	}
+
+	/**
+	*	Build query string and execute
+	*	@return static[]
+	*	@param $fields string
+	*	@param $filter string|array
+	*	@param $options array
+	*	@param $ttl int|array
+	**/
+	function select($fields,$filter=NULL,array $options=NULL,$ttl=0) {
+		list($sql,$args)=$this->stringify($fields,$filter,$options);
+		$result=$this->db->exec($sql,$args,$ttl);
+		$out=[];
+		foreach ($result as &$row) {
+			foreach ($row as $field=>&$val) {
+				if (array_key_exists($field,$this->fields)) {
+					if (!is_null($val) || !$this->fields[$field]['nullable'])
+						$val=$this->db->value(
+							$this->fields[$field]['pdo_type'],$val);
+				}
+				unset($val);
+			}
+			$out[]=$this->factory($row);
+			unset($row);
+		}
+		return $out;
+	}
+
+	/**
+	*	Return records that match criteria
+	*	@return static[]
+	*	@param $filter string|array
+	*	@param $options array
+	*	@param $ttl int|array
+	**/
+	function find($filter=NULL,array $options=NULL,$ttl=0) {
+		if (!$options)
+			$options=[];
+		$options+=[
+			'group'=>NULL,
+			'order'=>NULL,
+			'limit'=>0,
+			'offset'=>0
+		];
+		$adhoc='';
+		foreach ($this->adhoc as $key=>$field)
+			$adhoc.=','.$field['expr'].' AS '.$this->db->quotekey($key);
+		return $this->select(
+			($options['group'] && !preg_match('/mysql|sqlite/',$this->engine)?
+				$options['group']:
+				implode(',',array_map([$this->db,'quotekey'],
+					array_keys($this->fields)))).$adhoc,$filter,$options,$ttl);
+	}
+
+	/**
+	*	Count records that match criteria
+	*	@return int
+	*	@param $filter string|array
+	*	@param $options array
+	*	@param $ttl int|array
+	**/
+	function count($filter=NULL,array $options=NULL,$ttl=0) {
+		$adhoc='';
+		foreach ($this->adhoc as $key=>$field)
+			$adhoc.=','.$field['expr'].' AS '.$this->db->quotekey($key);
+		$fields='*'.$adhoc;
+		if (preg_match('/mssql|dblib|sqlsrv/',$this->engine))
+			$fields='TOP 100 PERCENT '.$fields;
+		list($sql,$args)=$this->stringify($fields,$filter,$options);
+		$sql='SELECT COUNT(*) AS '.$this->db->quotekey('_rows').' '.
+			'FROM ('.$sql.') AS '.$this->db->quotekey('_temp');
+		$result=$this->db->exec($sql,$args,$ttl);
+		return (int)$result[0]['_rows'];
+	}
+
+	/**
+	*	Return record at specified offset using same criteria as
+	*	previous load() call and make it active
+	*	@return static
+	*	@param $ofs int
+	**/
+	function skip($ofs=1) {
+		$out=parent::skip($ofs);
+		$dry=$this->dry();
+		foreach ($this->fields as $key=>&$field) {
+			$field['value']=$dry?NULL:$out->fields[$key]['value'];
+			$field['initial']=$field['value'];
+			$field['changed']=FALSE;
+			if ($field['pkey'])
+				$field['previous']=$dry?NULL:$out->fields[$key]['value'];
+			unset($field);
+		}
+		foreach ($this->adhoc as $key=>&$field) {
+			$field['value']=$dry?NULL:$out->adhoc[$key]['value'];
+			unset($field);
+		}
+		if (!$dry && isset($this->trigger['load']))
+			\Base::instance()->call($this->trigger['load'],$this);
+		return $out;
+	}
+
+	/**
+	*	Insert new record
+	*	@return static
+	**/
+	function insert() {
+		$args=[];
+		$actr=0;
+		$nctr=0;
+		$fields='';
+		$values='';
+		$filter='';
+		$pkeys=[];
+		$nkeys=[];
+		$ckeys=[];
+		$inc=NULL;
+		foreach ($this->fields as $key=>$field)
+			if ($field['pkey'])
+				$pkeys[$key]=$field['previous'];
+		if (isset($this->trigger['beforeinsert']) &&
+			\Base::instance()->call($this->trigger['beforeinsert'],
+				[$this,$pkeys])===FALSE)
+			return $this;
+		foreach ($this->fields as $key=>&$field) {
+			if ($field['pkey']) {
+				$field['previous']=$field['value'];
+				if (!$inc && $field['pdo_type']==\PDO::PARAM_INT &&
+					empty($field['value']) && !$field['nullable'])
+					$inc=$key;
+				$filter.=($filter?' AND ':'').$this->db->quotekey($key).'=?';
+				$nkeys[$nctr+1]=[$field['value'],$field['pdo_type']];
+				$nctr++;
+			}
+			if ($field['changed'] && $key!=$inc) {
+				$fields.=($actr?',':'').$this->db->quotekey($key);
+				$values.=($actr?',':'').'?';
+				$args[$actr+1]=[$field['value'],$field['pdo_type']];
+				$actr++;
+				$ckeys[]=$key;
+			}
+		}
+		if ($fields) {
+			$add=$aik='';
+			if ($this->engine=='pgsql' && !empty($pkeys)) {
+				$names=array_keys($pkeys);
+				$aik=end($names);
+				$add=' RETURNING '.$this->db->quotekey($aik);
+			}
+			$lID=$this->db->exec(
+				(preg_match('/mssql|dblib|sqlsrv/',$this->engine) &&
+				array_intersect(array_keys($pkeys),$ckeys)?
+					'SET IDENTITY_INSERT '.$this->table.' ON;':'').
+				'INSERT INTO '.$this->table.' ('.$fields.') '.
+				'VALUES ('.$values.')'.$add,$args
+			);
+			if ($this->engine=='pgsql' && $lID && $aik)
+				$this->_id=$lID[0][$aik];
+			elseif ($this->engine!='oci')
+				$this->_id=$this->db->lastinsertid();
+			// Reload to obtain default and auto-increment field values
+			if ($reload=(($inc && $this->_id) || $filter))
+				$this->load($inc?
+					[$inc.'=?',$this->db->value(
+						$this->fields[$inc]['pdo_type'],$this->_id)]:
+					[$filter,$nkeys]);
+			if (isset($this->trigger['afterinsert']))
+				\Base::instance()->call($this->trigger['afterinsert'],
+					[$this,$pkeys]);
+			// reset changed flag after calling afterinsert
+			if (!$reload)
+				foreach ($this->fields as $key=>&$field) {
+					$field['changed']=FALSE;
+					$field['initial']=$field['value'];
+					unset($field);
+				}
+		}
+		return $this;
+	}
+
+	/**
+	*	Update current record
+	*	@return static
+	**/
+	function update() {
+		$args=[];
+		$ctr=0;
+		$pairs='';
+		$filter='';
+		$pkeys=[];
+		foreach ($this->fields as $key=>$field)
+			if ($field['pkey'])
+				$pkeys[$key]=$field['previous'];
+		if (isset($this->trigger['beforeupdate']) &&
+			\Base::instance()->call($this->trigger['beforeupdate'],
+				[$this,$pkeys])===FALSE)
+			return $this;
+		foreach ($this->fields as $key=>$field)
+			if ($field['changed']) {
+				$pairs.=($pairs?',':'').$this->db->quotekey($key).'=?';
+				$args[++$ctr]=[$field['value'],$field['pdo_type']];
+			}
+		foreach ($this->fields as $key=>$field)
+			if ($field['pkey']) {
+				$filter.=($filter?' AND ':' WHERE ').
+					$this->db->quotekey($key).'=?';
+				$args[++$ctr]=[$field['previous'],$field['pdo_type']];
+			}
+		if ($pairs) {
+			$sql='UPDATE '.$this->table.' SET '.$pairs.$filter;
+			$this->db->exec($sql,$args);
+		}
+		if (isset($this->trigger['afterupdate']))
+			\Base::instance()->call($this->trigger['afterupdate'],
+				[$this,$pkeys]);
+		// reset changed flag after calling afterupdate
+		foreach ($this->fields as $key=>&$field) {
+				$field['changed']=FALSE;
+				$field['initial']=$field['value'];
+				unset($field);
+			}
+		return $this;
+	}
+
+	/**
+	*	Delete current record
+	*	@return int
+	*	@param $quick bool
+	*	@param $filter string|array
+	**/
+	function erase($filter=NULL,$quick=TRUE) {
+		if (isset($filter)) {
+			if (!$quick) {
+				$out=0;
+				foreach ($this->find($filter) as $mapper)
+					$out+=$mapper->erase();
+				return $out;
+			}
+			$args=[];
+			if (is_array($filter)) {
+				$args=isset($filter[1]) && is_array($filter[1])?
+					$filter[1]:
+					array_slice($filter,1,NULL,TRUE);
+				$args=is_array($args)?$args:[1=>$args];
+				list($filter)=$filter;
+			}
+			return $this->db->
+				exec('DELETE FROM '.$this->table.
+				($filter?' WHERE '.$filter:'').';',$args);
+		}
+		$args=[];
+		$ctr=0;
+		$filter='';
+		$pkeys=[];
+		foreach ($this->fields as $key=>&$field) {
+			if ($field['pkey']) {
+				$filter.=($filter?' AND ':'').$this->db->quotekey($key).'=?';
+				$args[$ctr+1]=[$field['previous'],$field['pdo_type']];
+				$pkeys[$key]=$field['previous'];
+				$ctr++;
+			}
+			$field['value']=NULL;
+			$field['changed']=(bool)$field['default'];
+			if ($field['pkey'])
+				$field['previous']=NULL;
+			unset($field);
+		}
+		foreach ($this->adhoc as &$field) {
+			$field['value']=NULL;
+			unset($field);
+		}
+		parent::erase();
+		if (isset($this->trigger['beforeerase']) &&
+			\Base::instance()->call($this->trigger['beforeerase'],
+				[$this,$pkeys])===FALSE)
+			return 0;
+		$out=$this->db->
+			exec('DELETE FROM '.$this->table.' WHERE '.$filter.';',$args);
+		if (isset($this->trigger['aftererase']))
+			\Base::instance()->call($this->trigger['aftererase'],
+				[$this,$pkeys]);
+		return $out;
+	}
+
+	/**
+	*	Reset cursor
+	*	@return NULL
+	**/
+	function reset() {
+		foreach ($this->fields as &$field) {
+			$field['value']=NULL;
+			$field['initial']=NULL;
+			$field['changed']=FALSE;
+			if ($field['pkey'])
+				$field['previous']=NULL;
+			unset($field);
+		}
+		foreach ($this->adhoc as &$field) {
+			$field['value']=NULL;
+			unset($field);
+		}
+		parent::reset();
+	}
+
+	/**
+	*	Hydrate mapper object using hive array variable
+	*	@return NULL
+	*	@param $var array|string
+	*	@param $func callback
+	**/
+	function copyfrom($var,$func=NULL) {
+		if (is_string($var))
+			$var=\Base::instance()->$var;
+		if ($func)
+			$var=call_user_func($func,$var);
+		foreach ($var as $key=>$val)
+			if (in_array($key,array_keys($this->fields)))
+				$this->set($key,$val);
+	}
+
+	/**
+	*	Populate hive array variable with mapper fields
+	*	@return NULL
+	*	@param $key string
+	**/
+	function copyto($key) {
+		$var=&\Base::instance()->ref($key);
+		foreach ($this->fields+$this->adhoc as $key=>$field)
+			$var[$key]=$field['value'];
+	}
+
+	/**
+	*	Return schema and, if the first argument is provided, update it
+	*	@return array
+	*	@param $fields NULL|array
+	**/
+	function schema($fields=null) {
+		if ($fields)
+			$this->fields = $fields;
+		return $this->fields;
+	}
+
+	/**
+	*	Return field names
+	*	@return array
+	*	@param $adhoc bool
+	**/
+	function fields($adhoc=TRUE) {
+		return array_keys($this->fields+($adhoc?$this->adhoc:[]));
+	}
+
+	/**
+	*	Return TRUE if field is not nullable
+	*	@return bool
+	*	@param $field string
+	**/
+	function required($field) {
+		return isset($this->fields[$field]) &&
+			!$this->fields[$field]['nullable'];
+	}
+
+	/**
+	*	Retrieve external iterator for fields
+	*	@return object
+	**/
+	function getiterator() {
+		return new \ArrayIterator($this->cast());
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $db \DB\SQL
+	*	@param $table string
+	*	@param $fields array|string
+	*	@param $ttl int|array
+	**/
+	function __construct(\DB\SQL $db,$table,$fields=NULL,$ttl=60) {
+		$this->db=$db;
+		$this->engine=$db->driver();
+		if ($this->engine=='oci')
+			$table=strtoupper($table);
+		$this->source=$table;
+		$this->table=$this->db->quotekey($table);
+		$this->fields=$db->schema($table,$fields,$ttl);
+		$this->reset();
+	}
+
+}

+ 217 - 0
lib/f3/db/sql/session.php

@@ -0,0 +1,217 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace DB\SQL;
+
+//! SQL-managed session handler
+class Session extends Mapper {
+
+	protected
+		//! Session ID
+		$sid,
+		//! Anti-CSRF token
+		$_csrf,
+		//! User agent
+		$_agent,
+		//! IP,
+		$_ip,
+		//! Suspect callback
+		$onsuspect;
+
+	/**
+	*	Open session
+	*	@return TRUE
+	*	@param $path string
+	*	@param $name string
+	**/
+	function open($path,$name) {
+		return TRUE;
+	}
+
+	/**
+	*	Close session
+	*	@return TRUE
+	**/
+	function close() {
+		$this->reset();
+		$this->sid=NULL;
+		return TRUE;
+	}
+
+	/**
+	*	Return session data in serialized format
+	*	@return string
+	*	@param $id string
+	**/
+	function read($id) {
+		$this->load(['session_id=?',$this->sid=$id]);
+		if ($this->dry())
+			return '';
+		if ($this->get('ip')!=$this->_ip || $this->get('agent')!=$this->_agent) {
+			$fw=\Base::instance();
+			if (!isset($this->onsuspect) ||
+				$fw->call($this->onsuspect,[$this,$id])===FALSE) {
+				//NB: `session_destroy` can't be called at that stage (`session_start` not completed)
+				$this->destroy($id);
+				$this->close();
+				unset($fw->{'COOKIE.'.session_name()});
+				$fw->error(403);
+			}
+		}
+		return $this->get('data');
+	}
+
+	/**
+	*	Write session data
+	*	@return TRUE
+	*	@param $id string
+	*	@param $data string
+	**/
+	function write($id,$data) {
+		$this->set('session_id',$id);
+		$this->set('data',$data);
+		$this->set('ip',$this->_ip);
+		$this->set('agent',$this->_agent);
+		$this->set('stamp',time());
+		$this->save();
+		return TRUE;
+	}
+
+	/**
+	*	Destroy session
+	*	@return TRUE
+	*	@param $id string
+	**/
+	function destroy($id) {
+		$this->erase(['session_id=?',$id]);
+		return TRUE;
+	}
+
+	/**
+	*	Garbage collector
+	*	@return TRUE
+	*	@param $max int
+	**/
+	function cleanup($max) {
+		$this->erase(['stamp+?<?',$max,time()]);
+		return TRUE;
+	}
+
+	/**
+	*	Return session id (if session has started)
+	*	@return string|NULL
+	**/
+	function sid() {
+		return $this->sid;
+	}
+
+	/**
+	*	Return anti-CSRF token
+	*	@return string
+	**/
+	function csrf() {
+		return $this->_csrf;
+	}
+
+	/**
+	*	Return IP address
+	*	@return string
+	**/
+	function ip() {
+		return $this->_ip;
+	}
+
+	/**
+	*	Return Unix timestamp
+	*	@return string|FALSE
+	**/
+	function stamp() {
+		if (!$this->sid)
+			session_start();
+		return $this->dry()?FALSE:$this->get('stamp');
+	}
+
+	/**
+	*	Return HTTP user agent
+	*	@return string
+	**/
+	function agent() {
+		return $this->_agent;
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $db \DB\SQL
+	*	@param $table string
+	*	@param $force bool
+	*	@param $onsuspect callback
+	*	@param $key string
+	**/
+	function __construct(\DB\SQL $db,$table='sessions',$force=TRUE,$onsuspect=NULL,$key=NULL) {
+		if ($force) {
+			$eol="\n";
+			$tab="\t";
+			$sqlsrv=preg_match('/mssql|sqlsrv|sybase/',$db->driver());
+			$db->exec(
+				($sqlsrv?
+					('IF NOT EXISTS (SELECT * FROM sysobjects WHERE '.
+						'name='.$db->quote($table).' AND xtype=\'U\') '.
+						'CREATE TABLE dbo.'):
+					('CREATE TABLE IF NOT EXISTS '.
+						((($name=$db->name())&&$db->driver()!='pgsql')?
+							($db->quotekey($name,FALSE).'.'):''))).
+				$db->quotekey($table,FALSE).' ('.$eol.
+					($sqlsrv?$tab.$db->quotekey('id').' INT IDENTITY,'.$eol:'').
+					$tab.$db->quotekey('session_id').' VARCHAR(255),'.$eol.
+					$tab.$db->quotekey('data').' TEXT,'.$eol.
+					$tab.$db->quotekey('ip').' VARCHAR(45),'.$eol.
+					$tab.$db->quotekey('agent').' VARCHAR(300),'.$eol.
+					$tab.$db->quotekey('stamp').' INTEGER,'.$eol.
+					$tab.'PRIMARY KEY ('.$db->quotekey($sqlsrv?'id':'session_id').')'.$eol.
+				($sqlsrv?',CONSTRAINT [UK_session_id] UNIQUE(session_id)':'').
+				');'
+			);
+		}
+		parent::__construct($db,$table);
+		$this->onsuspect=$onsuspect;
+		session_set_save_handler(
+			[$this,'open'],
+			[$this,'close'],
+			[$this,'read'],
+			[$this,'write'],
+			[$this,'destroy'],
+			[$this,'cleanup']
+		);
+		register_shutdown_function('session_commit');
+		$fw=\Base::instance();
+		$headers=$fw->HEADERS;
+		$this->_csrf=$fw->SEED.'.'.$fw->hash(mt_rand());
+		if ($key)
+			$fw->$key=$this->_csrf;
+		$this->_agent=isset($headers['User-Agent'])?$headers['User-Agent']:'';
+		if (strlen($this->_agent) > 300) {
+			$this->_agent = substr($this->_agent, 0, 300);
+		}
+		$this->_ip=$fw->IP;
+	}
+
+}

+ 42 - 0
lib/f3/f3.php

@@ -0,0 +1,42 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Legacy mode enabler
+class F3 {
+
+	static
+		//! Framework instance
+		$fw;
+
+	/**
+	*	Forward function calls to framework
+	*	@return mixed
+	*	@param $func callback
+	*	@param $args array
+	**/
+	static function __callstatic($func,array $args) {
+		if (!self::$fw)
+			self::$fw=Base::instance();
+		return call_user_func_array([self::$fw,$func],$args);
+	}
+
+}

+ 616 - 0
lib/f3/image.php

@@ -0,0 +1,616 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Image manipulation tools
+class Image {
+
+	//@{ Messages
+	const
+		E_Color='Invalid color specified: %s',
+		E_File='File not found',
+		E_Font='CAPTCHA font not found',
+		E_TTF='No TrueType support in GD module',
+		E_Length='Invalid CAPTCHA length: %s';
+	//@}
+
+	//@{ Positional cues
+	const
+		POS_Left=1,
+		POS_Center=2,
+		POS_Right=4,
+		POS_Top=8,
+		POS_Middle=16,
+		POS_Bottom=32;
+	//@}
+
+	protected
+		//! Source filename
+		$file,
+		//! Image resource
+		$data,
+		//! Enable/disable history
+		$flag=FALSE,
+		//! Filter count
+		$count=0;
+
+	/**
+	*	Convert RGB hex triad to array
+	*	@return array|FALSE
+	*	@param $color int|string
+	**/
+	function rgb($color) {
+		if (is_string($color))
+			$color=hexdec($color);
+		$hex=str_pad($hex=dechex($color),$color<4096?3:6,'0',STR_PAD_LEFT);
+		if (($len=strlen($hex))>6)
+			user_error(sprintf(self::E_Color,'0x'.$hex),E_USER_ERROR);
+		$color=str_split($hex,$len/3);
+		foreach ($color as &$hue) {
+			$hue=hexdec(str_repeat($hue,6/$len));
+			unset($hue);
+		}
+		return $color;
+	}
+
+	/**
+	*	Invert image
+	*	@return object
+	**/
+	function invert() {
+		imagefilter($this->data,IMG_FILTER_NEGATE);
+		return $this->save();
+	}
+
+	/**
+	*	Adjust brightness (range:-255 to 255)
+	*	@return object
+	*	@param $level int
+	**/
+	function brightness($level) {
+		imagefilter($this->data,IMG_FILTER_BRIGHTNESS,$level);
+		return $this->save();
+	}
+
+	/**
+	*	Adjust contrast (range:-100 to 100)
+	*	@return object
+	*	@param $level int
+	**/
+	function contrast($level) {
+		imagefilter($this->data,IMG_FILTER_CONTRAST,$level);
+		return $this->save();
+	}
+
+	/**
+	*	Convert to grayscale
+	*	@return object
+	**/
+	function grayscale() {
+		imagefilter($this->data,IMG_FILTER_GRAYSCALE);
+		return $this->save();
+	}
+
+	/**
+	*	Adjust smoothness
+	*	@return object
+	*	@param $level int
+	**/
+	function smooth($level) {
+		imagefilter($this->data,IMG_FILTER_SMOOTH,$level);
+		return $this->save();
+	}
+
+	/**
+	*	Emboss the image
+	*	@return object
+	**/
+	function emboss() {
+		imagefilter($this->data,IMG_FILTER_EMBOSS);
+		return $this->save();
+	}
+
+	/**
+	*	Apply sepia effect
+	*	@return object
+	**/
+	function sepia() {
+		imagefilter($this->data,IMG_FILTER_GRAYSCALE);
+		imagefilter($this->data,IMG_FILTER_COLORIZE,90,60,45);
+		return $this->save();
+	}
+
+	/**
+	*	Pixelate the image
+	*	@return object
+	*	@param $size int
+	**/
+	function pixelate($size) {
+		imagefilter($this->data,IMG_FILTER_PIXELATE,$size,TRUE);
+		return $this->save();
+	}
+
+	/**
+	*	Blur the image using Gaussian filter
+	*	@return object
+	*	@param $selective bool
+	**/
+	function blur($selective=FALSE) {
+		imagefilter($this->data,
+			$selective?IMG_FILTER_SELECTIVE_BLUR:IMG_FILTER_GAUSSIAN_BLUR);
+		return $this->save();
+	}
+
+	/**
+	*	Apply sketch effect
+	*	@return object
+	**/
+	function sketch() {
+		imagefilter($this->data,IMG_FILTER_MEAN_REMOVAL);
+		return $this->save();
+	}
+
+	/**
+	*	Flip on horizontal axis
+	*	@return object
+	**/
+	function hflip() {
+		$tmp=imagecreatetruecolor(
+			$width=$this->width(),$height=$this->height());
+		imagesavealpha($tmp,TRUE);
+		imagefill($tmp,0,0,IMG_COLOR_TRANSPARENT);
+		imagecopyresampled($tmp,$this->data,
+			0,0,$width-1,0,$width,$height,-$width,$height);
+		imagedestroy($this->data);
+		$this->data=$tmp;
+		return $this->save();
+	}
+
+	/**
+	*	Flip on vertical axis
+	*	@return object
+	**/
+	function vflip() {
+		$tmp=imagecreatetruecolor(
+			$width=$this->width(),$height=$this->height());
+		imagesavealpha($tmp,TRUE);
+		imagefill($tmp,0,0,IMG_COLOR_TRANSPARENT);
+		imagecopyresampled($tmp,$this->data,
+			0,0,0,$height-1,$width,$height,$width,-$height);
+		imagedestroy($this->data);
+		$this->data=$tmp;
+		return $this->save();
+	}
+
+	/**
+	*	Crop the image
+	*	@return object
+	*	@param $x1 int
+	*	@param $y1 int
+	*	@param $x2 int
+	*	@param $y2 int
+	**/
+	function crop($x1,$y1,$x2,$y2) {
+		$tmp=imagecreatetruecolor($width=$x2-$x1+1,$height=$y2-$y1+1);
+		imagesavealpha($tmp,TRUE);
+		imagefill($tmp,0,0,IMG_COLOR_TRANSPARENT);
+		imagecopyresampled($tmp,$this->data,
+			0,0,$x1,$y1,$width,$height,$width,$height);
+		imagedestroy($this->data);
+		$this->data=$tmp;
+		return $this->save();
+	}
+
+	/**
+	*	Resize image (Maintain aspect ratio); Crop relative to center
+	*	if flag is enabled; Enlargement allowed if flag is enabled
+	*	@return object
+	*	@param $width int
+	*	@param $height int
+	*	@param $crop bool
+	*	@param $enlarge bool
+	**/
+	function resize($width=NULL,$height=NULL,$crop=TRUE,$enlarge=TRUE) {
+		if (is_null($width) && is_null($height))
+			return $this;
+		$origw=$this->width();
+		$origh=$this->height();
+		if (is_null($width))
+			$width=round(($height/$origh)*$origw);
+		if (is_null($height))
+			$height=round(($width/$origw)*$origh);
+		// Adjust dimensions; retain aspect ratio
+		$ratio=$origw/$origh;
+		if (!$crop) {
+			if ($width/$ratio<=$height)
+				$height=round($width/$ratio);
+			else
+				$width=round($height*$ratio);
+		}
+		if (!$enlarge) {
+			$width=min($origw,$width);
+			$height=min($origh,$height);
+		}
+		// Create blank image
+		$tmp=imagecreatetruecolor($width,$height);
+		imagesavealpha($tmp,TRUE);
+		imagefill($tmp,0,0,IMG_COLOR_TRANSPARENT);
+		// Resize
+		if ($crop) {
+			if ($width/$ratio<=$height) {
+				$cropw=round($origh*$width/$height);
+				imagecopyresampled($tmp,$this->data,
+					0,0,($origw-$cropw)/2,0,$width,$height,$cropw,$origh);
+			}
+			else {
+				$croph=round($origw*$height/$width);
+				imagecopyresampled($tmp,$this->data,
+					0,0,0,($origh-$croph)/2,$width,$height,$origw,$croph);
+			}
+		}
+		else
+			imagecopyresampled($tmp,$this->data,
+				0,0,0,0,$width,$height,$origw,$origh);
+		imagedestroy($this->data);
+		$this->data=$tmp;
+		return $this->save();
+	}
+
+	/**
+	*	Rotate image
+	*	@return object
+	*	@param $angle int
+	**/
+	function rotate($angle) {
+		$this->data=imagerotate($this->data,$angle,
+			imagecolorallocatealpha($this->data,0,0,0,127));
+		imagesavealpha($this->data,TRUE);
+		return $this->save();
+	}
+
+	/**
+	*	Apply an image overlay
+	*	@return object
+	*	@param $img object
+	*	@param $align int|array
+	*	@param $alpha int
+	**/
+	function overlay(Image $img,$align=NULL,$alpha=100) {
+		if (is_null($align))
+			$align=self::POS_Right|self::POS_Bottom;
+		if (is_array($align)) {
+			list($posx,$posy)=$align;
+			$align = 0;
+		}
+		$ovr=imagecreatefromstring($img->dump());
+		imagesavealpha($ovr,TRUE);
+		$imgw=$this->width();
+		$imgh=$this->height();
+		$ovrw=imagesx($ovr);
+		$ovrh=imagesy($ovr);
+		if ($align & self::POS_Left)
+			$posx=0;
+		if ($align & self::POS_Center)
+			$posx=($imgw-$ovrw)/2;
+		if ($align & self::POS_Right)
+			$posx=$imgw-$ovrw;
+		if ($align & self::POS_Top)
+			$posy=0;
+		if ($align & self::POS_Middle)
+			$posy=($imgh-$ovrh)/2;
+		if ($align & self::POS_Bottom)
+			$posy=$imgh-$ovrh;
+		if (empty($posx))
+			$posx=0;
+		if (empty($posy))
+			$posy=0;
+		if ($alpha==100)
+			imagecopy($this->data,$ovr,$posx,$posy,0,0,$ovrw,$ovrh);
+		else {
+			$cut=imagecreatetruecolor($ovrw,$ovrh);
+			imagecopy($cut,$this->data,0,0,$posx,$posy,$ovrw,$ovrh);
+			imagecopy($cut,$ovr,0,0,0,0,$ovrw,$ovrh);
+			imagecopymerge($this->data,
+				$cut,$posx,$posy,0,0,$ovrw,$ovrh,$alpha);
+		}
+		return $this->save();
+	}
+
+	/**
+	*	Generate identicon
+	*	@return object
+	*	@param $str string
+	*	@param $size int
+	*	@param $blocks int
+	**/
+	function identicon($str,$size=64,$blocks=4) {
+		$sprites=[
+			[.5,1,1,0,1,1],
+			[.5,0,1,0,.5,1,0,1],
+			[.5,0,1,0,1,1,.5,1,1,.5],
+			[0,.5,.5,0,1,.5,.5,1,.5,.5],
+			[0,.5,1,0,1,1,0,1,1,.5],
+			[1,0,1,1,.5,1,1,.5,.5,.5],
+			[0,0,1,0,1,.5,0,0,.5,1,0,1],
+			[0,0,.5,0,1,.5,.5,1,0,1,.5,.5],
+			[.5,0,.5,.5,1,.5,1,1,.5,1,.5,.5,0,.5],
+			[0,0,1,0,.5,.5,1,.5,.5,1,.5,.5,0,1],
+			[0,.5,.5,1,1,.5,.5,0,1,0,1,1,0,1],
+			[.5,0,1,0,1,1,.5,1,1,.75,.5,.5,1,.25],
+			[0,.5,.5,0,.5,.5,1,0,1,.5,.5,1,.5,.5,0,1],
+			[0,0,1,0,1,1,0,1,1,.5,.5,.25,.5,.75,0,.5,.5,.25],
+			[0,.5,.5,.5,.5,0,1,0,.5,.5,1,.5,.5,1,.5,.5,0,1],
+			[0,0,1,0,.5,.5,.5,0,0,.5,1,.5,.5,1,.5,.5,0,1]
+		];
+		$hash=sha1($str);
+		$this->data=imagecreatetruecolor($size,$size);
+		list($r,$g,$b)=$this->rgb(hexdec(substr($hash,-3)));
+		$fg=imagecolorallocate($this->data,$r,$g,$b);
+		imagefill($this->data,0,0,IMG_COLOR_TRANSPARENT);
+		$ctr=count($sprites);
+		$dim=$blocks*floor($size/$blocks)*2/$blocks;
+		for ($j=0,$y=ceil($blocks/2);$j<$y;$j++)
+			for ($i=$j,$x=$blocks-1-$j;$i<$x;$i++) {
+				$sprite=imagecreatetruecolor($dim,$dim);
+				imagefill($sprite,0,0,IMG_COLOR_TRANSPARENT);
+				$block=$sprites[hexdec($hash[($j*$blocks+$i)*2])%$ctr];
+				for ($k=0,$pts=count($block);$k<$pts;$k++)
+					$block[$k]*=$dim;
+				imagefilledpolygon($sprite,$block,$pts/2,$fg);
+				for ($k=0;$k<4;$k++) {
+					imagecopyresampled($this->data,$sprite,
+						$i*$dim/2,$j*$dim/2,0,0,$dim/2,$dim/2,$dim,$dim);
+					$this->data=imagerotate($this->data,90,
+						imagecolorallocatealpha($this->data,0,0,0,127));
+				}
+				imagedestroy($sprite);
+			}
+		imagesavealpha($this->data,TRUE);
+		return $this->save();
+	}
+
+	/**
+	*	Generate CAPTCHA image
+	*	@return object|FALSE
+	*	@param $font string
+	*	@param $size int
+	*	@param $len int
+	*	@param $key string
+	*	@param $path string
+	*	@param $fg int
+	*	@param $bg int
+	**/
+	function captcha($font,$size=24,$len=5,
+		$key=NULL,$path='',$fg=0xFFFFFF,$bg=0x000000) {
+		if ((!$ssl=extension_loaded('openssl')) && ($len<4 || $len>13)) {
+			user_error(sprintf(self::E_Length,$len),E_USER_ERROR);
+			return FALSE;
+		}
+		if (!function_exists('imagettftext')) {
+			user_error(self::E_TTF,E_USER_ERROR);
+			return FALSE;
+		}
+		$fw=Base::instance();
+		foreach ($fw->split($path?:$fw->UI.';./') as $dir)
+			if (is_file($path=$dir.$font)) {
+				$seed=strtoupper(substr(
+					$ssl?bin2hex(openssl_random_pseudo_bytes($len)):uniqid(),
+					-$len));
+				$block=$size*3;
+				$tmp=[];
+				for ($i=0,$width=0,$height=0;$i<$len;$i++) {
+					// Process at 2x magnification
+					$box=imagettfbbox($size*2,0,$path,$seed[$i]);
+					$w=$box[2]-$box[0];
+					$h=$box[1]-$box[5];
+					$char=imagecreatetruecolor($block,$block);
+					imagefill($char,0,0,$bg);
+					imagettftext($char,$size*2,0,
+						($block-$w)/2,$block-($block-$h)/2,
+						$fg,$path,$seed[$i]);
+					$char=imagerotate($char,mt_rand(-30,30),
+						imagecolorallocatealpha($char,0,0,0,127));
+					// Reduce to normal size
+					$tmp[$i]=imagecreatetruecolor(
+						($w=imagesx($char))/2,($h=imagesy($char))/2);
+					imagefill($tmp[$i],0,0,IMG_COLOR_TRANSPARENT);
+					imagecopyresampled($tmp[$i],
+						$char,0,0,0,0,$w/2,$h/2,$w,$h);
+					imagedestroy($char);
+					$width+=$i+1<$len?$block/2:$w/2;
+					$height=max($height,$h/2);
+				}
+				$this->data=imagecreatetruecolor($width,$height);
+				imagefill($this->data,0,0,IMG_COLOR_TRANSPARENT);
+				for ($i=0;$i<$len;$i++) {
+					imagecopy($this->data,$tmp[$i],
+						$i*$block/2,($height-imagesy($tmp[$i]))/2,0,0,
+						imagesx($tmp[$i]),imagesy($tmp[$i]));
+					imagedestroy($tmp[$i]);
+				}
+				imagesavealpha($this->data,TRUE);
+				if ($key)
+					$fw->$key=$seed;
+				return $this->save();
+			}
+		user_error(self::E_Font,E_USER_ERROR);
+		return FALSE;
+	}
+
+	/**
+	*	Return image width
+	*	@return int
+	**/
+	function width() {
+		return imagesx($this->data);
+	}
+
+	/**
+	*	Return image height
+	*	@return int
+	**/
+	function height() {
+		return imagesy($this->data);
+	}
+
+	/**
+	*	Send image to HTTP client
+	*	@return NULL
+	**/
+	function render() {
+		$args=func_get_args();
+		$format=$args?array_shift($args):'png';
+		if (PHP_SAPI!='cli') {
+			header('Content-Type: image/'.$format);
+			header('X-Powered-By: '.Base::instance()->PACKAGE);
+		}
+		call_user_func_array(
+			'image'.$format,
+			array_merge([$this->data,NULL],$args)
+		);
+	}
+
+	/**
+	*	Return image as a string
+	*	@return string
+	**/
+	function dump() {
+		$args=func_get_args();
+		$format=$args?array_shift($args):'png';
+		ob_start();
+		call_user_func_array(
+			'image'.$format,
+			array_merge([$this->data,NULL],$args)
+		);
+		return ob_get_clean();
+	}
+
+	/**
+	*	Return image resource
+	*	@return resource
+	**/
+	function data() {
+		return $this->data;
+	}
+
+	/**
+	*	Save current state
+	*	@return object
+	**/
+	function save() {
+		$fw=Base::instance();
+		if ($this->flag) {
+			if (!is_dir($dir=$fw->TEMP))
+				mkdir($dir,Base::MODE,TRUE);
+			$this->count++;
+			$fw->write($dir.'/'.$fw->SEED.'.'.
+				$fw->hash($this->file).'-'.$this->count.'.png',
+				$this->dump());
+		}
+		return $this;
+	}
+
+	/**
+	*	Revert to specified state
+	*	@return object
+	*	@param $state int
+	**/
+	function restore($state=1) {
+		$fw=Base::instance();
+		if ($this->flag && is_file($file=($path=$fw->TEMP.
+			$fw->SEED.'.'.$fw->hash($this->file).'-').$state.'.png')) {
+			if (is_resource($this->data))
+				imagedestroy($this->data);
+			$this->data=imagecreatefromstring($fw->read($file));
+			imagesavealpha($this->data,TRUE);
+			foreach (glob($path.'*.png',GLOB_NOSORT) as $match)
+				if (preg_match('/-(\d+)\.png/',$match,$parts) &&
+					$parts[1]>$state)
+					@unlink($match);
+			$this->count=$state;
+		}
+		return $this;
+	}
+
+	/**
+	*	Undo most recently applied filter
+	*	@return object
+	**/
+	function undo() {
+		if ($this->flag) {
+			if ($this->count)
+				$this->count--;
+			return $this->restore($this->count);
+		}
+		return $this;
+	}
+
+	/**
+	*	Load string
+	*	@return object|FALSE
+	*	@param $str string
+	**/
+	function load($str) {
+		if (!$this->data=@imagecreatefromstring($str))
+			return FALSE;
+		imagesavealpha($this->data,TRUE);
+		$this->save();
+		return $this;
+	}
+
+	/**
+	*	Instantiate image
+	*	@param $file string
+	*	@param $flag bool
+	*	@param $path string
+	**/
+	function __construct($file=NULL,$flag=FALSE,$path=NULL) {
+		$this->flag=$flag;
+		if ($file) {
+			$fw=Base::instance();
+			// Create image from file
+			$this->file=$file;
+			if (!isset($path))
+				$path=$fw->UI.';./';
+			foreach ($fw->split($path,FALSE) as $dir)
+				if (is_file($dir.$file))
+					return $this->load($fw->read($dir.$file));
+			user_error(self::E_File,E_USER_ERROR);
+		}
+	}
+
+	/**
+	*	Wrap-up
+	*	@return NULL
+	**/
+	function __destruct() {
+		if (is_resource($this->data)) {
+			imagedestroy($this->data);
+			$fw=Base::instance();
+			$path=$fw->TEMP.$fw->SEED.'.'.$fw->hash($this->file);
+			if ($glob=@glob($path.'*.png',GLOB_NOSORT))
+				foreach ($glob as $match)
+					if (preg_match('/-(\d+)\.png/',$match))
+						@unlink($match);
+		}
+	}
+
+}

+ 67 - 0
lib/f3/log.php

@@ -0,0 +1,67 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Custom logger
+class Log {
+
+	protected
+		//! File name
+		$file;
+
+	/**
+	*	Write specified text to log file
+	*	@return string
+	*	@param $text string
+	*	@param $format string
+	**/
+	function write($text,$format='r') {
+		$fw=Base::instance();
+		$fw->write(
+			$this->file,
+			date($format).
+				(isset($_SERVER['REMOTE_ADDR'])?
+					(' ['.$_SERVER['REMOTE_ADDR'].']'):'').' '.
+			trim($text).PHP_EOL,
+			TRUE
+		);
+	}
+
+	/**
+	*	Erase log
+	*	@return NULL
+	**/
+	function erase() {
+		@unlink($this->file);
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $file string
+	**/
+	function __construct($file) {
+		$fw=Base::instance();
+		if (!is_dir($dir=$fw->LOGS))
+			mkdir($dir,Base::MODE,TRUE);
+		$this->file=$dir.$file;
+	}
+
+}

+ 139 - 0
lib/f3/magic.php

@@ -0,0 +1,139 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! PHP magic wrapper
+abstract class Magic implements ArrayAccess {
+
+	/**
+	*	Return TRUE if key is not empty
+	*	@return bool
+	*	@param $key string
+	**/
+	abstract function exists($key);
+
+	/**
+	*	Bind value to key
+	*	@return mixed
+	*	@param $key string
+	*	@param $val mixed
+	**/
+	abstract function set($key,$val);
+
+	/**
+	*	Retrieve contents of key
+	*	@return mixed
+	*	@param $key string
+	**/
+	abstract function &get($key);
+
+	/**
+	*	Unset key
+	*	@return NULL
+	*	@param $key string
+	**/
+	abstract function clear($key);
+
+	/**
+	*	Convenience method for checking property value
+	*	@return mixed
+	*	@param $key string
+	**/
+	function offsetexists($key) {
+		return Base::instance()->visible($this,$key)?
+			isset($this->$key):$this->exists($key);
+	}
+
+	/**
+	*	Convenience method for assigning property value
+	*	@return mixed
+	*	@param $key string
+	*	@param $val scalar
+	**/
+	function offsetset($key,$val) {
+		return Base::instance()->visible($this,$key)?
+			($this->$key=$val):$this->set($key,$val);
+	}
+
+	/**
+	*	Convenience method for retrieving property value
+	*	@return mixed
+	*	@param $key string
+	**/
+	function &offsetget($key) {
+		if (Base::instance()->visible($this,$key))
+			$val=&$this->$key;
+		else
+			$val=&$this->get($key);
+		return $val;
+	}
+
+	/**
+	*	Convenience method for removing property value
+	*	@return NULL
+	*	@param $key string
+	**/
+	function offsetunset($key) {
+		if (Base::instance()->visible($this,$key))
+			unset($this->$key);
+		else
+			$this->clear($key);
+	}
+
+	/**
+	*	Alias for offsetexists()
+	*	@return mixed
+	*	@param $key string
+	**/
+	function __isset($key) {
+		return $this->offsetexists($key);
+	}
+
+	/**
+	*	Alias for offsetset()
+	*	@return mixed
+	*	@param $key string
+	*	@param $val scalar
+	**/
+	function __set($key,$val) {
+		return $this->offsetset($key,$val);
+	}
+
+	/**
+	*	Alias for offsetget()
+	*	@return mixed
+	*	@param $key string
+	**/
+	function &__get($key) {
+		$val=&$this->offsetget($key);
+		return $val;
+	}
+
+	/**
+	*	Alias for offsetunset()
+	*	@return NULL
+	*	@param $key string
+	**/
+	function __unset($key) {
+		$this->offsetunset($key);
+	}
+
+}

+ 572 - 0
lib/f3/markdown.php

@@ -0,0 +1,572 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Markdown-to-HTML converter
+class Markdown extends Prefab {
+
+	protected
+		//! Parsing rules
+		$blocks,
+		//! Special characters
+		$special;
+
+	/**
+	*	Process blockquote
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _blockquote($str) {
+		$str=preg_replace('/(?<=^|\n)\h?>\h?(.*?(?:\n+|$))/','\1',$str);
+		return strlen($str)?
+			('<blockquote>'.$this->build($str).'</blockquote>'."\n\n"):'';
+	}
+
+	/**
+	*	Process whitespace-prefixed code block
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _pre($str) {
+		$str=preg_replace('/(?<=^|\n)(?: {4}|\t)(.+?(?:\n+|$))/','\1',
+			$this->esc($str));
+		return strlen($str)?
+			('<pre><code>'.
+				$this->esc($this->snip($str)).
+			'</code></pre>'."\n\n"):
+			'';
+	}
+
+	/**
+	*	Process fenced code block
+	*	@return string
+	*	@param $hint string
+	*	@param $str string
+	**/
+	protected function _fence($hint,$str) {
+		$str=$this->snip($str);
+		$fw=Base::instance();
+		if ($fw->HIGHLIGHT) {
+			switch (strtolower($hint)) {
+				case 'php':
+					$str=$fw->highlight($str);
+					break;
+				case 'apache':
+					preg_match_all('/(?<=^|\n)(\h*)'.
+						'(?:(<\/?)(\w+)((?:\h+[^>]+)*)(>)|'.
+						'(?:(\w+)(\h.+?)))(\h*(?:\n+|$))/',
+						$str,$matches,PREG_SET_ORDER);
+					$out='';
+					foreach ($matches as $match)
+						$out.=$match[1].
+							($match[3]?
+								('<span class="section">'.
+									$this->esc($match[2]).$match[3].
+								'</span>'.
+								($match[4]?
+									('<span class="data">'.
+										$this->esc($match[4]).
+									'</span>'):
+									'').
+								'<span class="section">'.
+									$this->esc($match[5]).
+								'</span>'):
+								('<span class="directive">'.
+									$match[6].
+								'</span>'.
+								'<span class="data">'.
+									$this->esc($match[7]).
+								'</span>')).
+							$match[8];
+					$str='<code>'.$out.'</code>';
+					break;
+				case 'html':
+					preg_match_all(
+						'/(?:(?:<(\/?)(\w+)'.
+						'((?:\h+(?:\w+\h*=\h*)?".+?"|[^>]+)*|'.
+						'\h+.+?)(\h*\/?)>)|(.+?))/s',
+						$str,$matches,PREG_SET_ORDER
+					);
+					$out='';
+					foreach ($matches as $match) {
+						if ($match[2]) {
+							$out.='<span class="xml_tag">&lt;'.
+								$match[1].$match[2].'</span>';
+							if ($match[3]) {
+								preg_match_all(
+									'/(?:\h+(?:(?:(\w+)\h*=\h*)?'.
+									'(".+?")|(.+)))/',
+									$match[3],$parts,PREG_SET_ORDER
+								);
+								foreach ($parts as $part)
+									$out.=' '.
+										(empty($part[3])?
+											((empty($part[1])?
+												'':
+												('<span class="xml_attr">'.
+													$part[1].'</span>=')).
+											'<span class="xml_data">'.
+												$part[2].'</span>'):
+											('<span class="xml_tag">'.
+												$part[3].'</span>'));
+							}
+							$out.='<span class="xml_tag">'.
+								$match[4].'&gt;</span>';
+						}
+						else
+							$out.=$this->esc($match[5]);
+					}
+					$str='<code>'.$out.'</code>';
+					break;
+				case 'ini':
+					preg_match_all(
+						'/(?<=^|\n)(?:'.
+						'(;[^\n]*)|(?:<\?php.+?\?>?)|'.
+						'(?:\[(.+?)\])|'.
+						'(.+?)\h*=\h*'.
+						'((?:\\\\\h*\r?\n|.+?)*)'.
+						')((?:\r?\n)+|$)/',
+						$str,$matches,PREG_SET_ORDER
+					);
+					$out='';
+					foreach ($matches as $match) {
+						if ($match[1])
+							$out.='<span class="comment">'.$match[1].
+								'</span>';
+						elseif ($match[2])
+							$out.='<span class="ini_section">['.$match[2].']'.
+								'</span>';
+						elseif ($match[3])
+							$out.='<span class="ini_key">'.$match[3].
+								'</span>='.
+								($match[4]?
+									('<span class="ini_value">'.
+										$match[4].'</span>'):'');
+						else
+							$out.=$match[0];
+						if (isset($match[5]))
+							$out.=$match[5];
+					}
+					$str='<code>'.$out.'</code>';
+					break;
+				default:
+					$str='<code>'.$this->esc($str).'</code>';
+					break;
+			}
+		}
+		else
+			$str='<code>'.$this->esc($str).'</code>';
+		return '<pre>'.$str.'</pre>'."\n\n";
+	}
+
+	/**
+	*	Process horizontal rule
+	*	@return string
+	**/
+	protected function _hr() {
+		return '<hr />'."\n\n";
+	}
+
+	/**
+	*	Process atx-style heading
+	*	@return string
+	*	@param $type string
+	*	@param $str string
+	**/
+	protected function _atx($type,$str) {
+		$level=strlen($type);
+		return '<h'.$level.' id="'.Web::instance()->slug($str).'">'.
+			$this->scan($str).'</h'.$level.'>'."\n\n";
+	}
+
+	/**
+	*	Process setext-style heading
+	*	@return string
+	*	@param $str string
+	*	@param $type string
+	**/
+	protected function _setext($str,$type) {
+		$level=strpos('=-',$type)+1;
+		return '<h'.$level.' id="'.Web::instance()->slug($str).'">'.
+			$this->scan($str).'</h'.$level.'>'."\n\n";
+	}
+
+	/**
+	*	Process ordered/unordered list
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _li($str) {
+		// Initialize list parser
+		$len=strlen($str);
+		$ptr=0;
+		$dst='';
+		$first=TRUE;
+		$tight=TRUE;
+		$type='ul';
+		// Main loop
+		while ($ptr<$len) {
+			if (preg_match('/^\h*[*-](?:\h?[*-]){2,}(?:\n+|$)/',
+				substr($str,$ptr),$match)) {
+				$ptr+=strlen($match[0]);
+				// Embedded horizontal rule
+				return (strlen($dst)?
+					('<'.$type.'>'."\n".$dst.'</'.$type.'>'."\n\n"):'').
+					'<hr />'."\n\n".$this->build(substr($str,$ptr));
+			}
+			elseif (preg_match('/(?<=^|\n)([*+-]|\d+\.)\h'.
+				'(.+?(?:\n+|$))((?:(?: {4}|\t)+.+?(?:\n+|$))*)/s',
+				substr($str,$ptr),$match)) {
+				$match[3]=preg_replace('/(?<=^|\n)(?: {4}|\t)/','',$match[3]);
+				$found=FALSE;
+				foreach (array_slice($this->blocks,0,-1) as $regex)
+					if (preg_match($regex,$match[3])) {
+						$found=TRUE;
+						break;
+					}
+				// List
+				if ($first) {
+					// First pass
+					if (is_numeric($match[1]))
+						$type='ol';
+					if (preg_match('/\n{2,}$/',$match[2].
+						($found?'':$match[3])))
+						// Loose structure; Use paragraphs
+						$tight=FALSE;
+					$first=FALSE;
+				}
+				// Strip leading whitespaces
+				$ptr+=strlen($match[0]);
+				$tmp=$this->snip($match[2].$match[3]);
+				if ($tight) {
+					if ($found)
+						$tmp=$match[2].$this->build($this->snip($match[3]));
+				}
+				else
+					$tmp=$this->build($tmp);
+				$dst.='<li>'.$this->scan(trim($tmp)).'</li>'."\n";
+			}
+		}
+		return strlen($dst)?
+			('<'.$type.'>'."\n".$dst.'</'.$type.'>'."\n\n"):'';
+	}
+
+	/**
+	*	Ignore raw HTML
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _raw($str) {
+		return $str;
+	}
+
+	/**
+	*	Process paragraph
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _p($str) {
+		$str=trim($str);
+		if (strlen($str)) {
+			if (preg_match('/^(.+?\n)([>#].+)$/s',$str,$parts))
+				return $this->_p($parts[1]).$this->build($parts[2]);
+			$str=preg_replace_callback(
+				'/([^<>\[]+)?(<[\?%].+?[\?%]>|<.+?>|\[.+?\]\s*\(.+?\))|'.
+				'(.+)/s',
+				function($expr) {
+					$tmp='';
+					if (isset($expr[4]))
+						$tmp.=$this->esc($expr[4]);
+					else {
+						if (isset($expr[1]))
+							$tmp.=$this->esc($expr[1]);
+						$tmp.=$expr[2];
+						if (isset($expr[3]))
+							$tmp.=$this->esc($expr[3]);
+					}
+					return $tmp;
+				},
+				$str
+			);
+			$str=preg_replace('/\s{2}\r?\n/','<br />',$str);
+			return '<p>'.$this->scan($str).'</p>'."\n\n";
+		}
+		return '';
+	}
+
+	/**
+	*	Process strong/em/strikethrough spans
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _text($str) {
+		$tmp='';
+		while ($str!=$tmp)
+			$str=preg_replace_callback(
+				'/(?<=\s|^)(?<!\\\\)([*_]{1,3})(.*?)(?!\\\\)\1(?=[\s[:punct:]]|$)/',
+				function($expr) {
+					switch (strlen($expr[1])) {
+						case 1:
+							return '<em>'.$expr[2].'</em>';
+						case 2:
+							return '<strong>'.$expr[2].'</strong>';
+						case 3:
+							return '<strong><em>'.$expr[2].'</em></strong>';
+					}
+				},
+				preg_replace(
+					'/(?<!\\\\)~~(.*?)(?!\\\\)~~(?=[\s[:punct:]]|$)/',
+					'<del>\1</del>',
+					$tmp=$str
+				)
+			);
+		return $str;
+	}
+
+	/**
+	*	Process image span
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _img($str) {
+		return preg_replace_callback(
+			'/!(?:\[(.+?)\])?\h*\(<?(.*?)>?(?:\h*"(.*?)"\h*)?\)/',
+			function($expr) {
+				return '<img src="'.$expr[2].'"'.
+					(empty($expr[1])?
+						'':
+						(' alt="'.$this->esc($expr[1]).'"')).
+					(empty($expr[3])?
+						'':
+						(' title="'.$this->esc($expr[3]).'"')).' />';
+			},
+			$str
+		);
+	}
+
+	/**
+	*	Process anchor span
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _a($str) {
+		return preg_replace_callback(
+			'/(?<!\\\\)\[(.+?)(?!\\\\)\]\h*\(<?(.*?)>?(?:\h*"(.*?)"\h*)?\)/',
+			function($expr) {
+				return '<a href="'.$this->esc($expr[2]).'"'.
+					(empty($expr[3])?
+						'':
+						(' title="'.$this->esc($expr[3]).'"')).
+					'>'.$this->scan($expr[1]).'</a>';
+			},
+			$str
+		);
+	}
+
+	/**
+	*	Auto-convert links
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _auto($str) {
+		return preg_replace_callback(
+			'/`.*?<(.+?)>.*?`|<(.+?)>/',
+			function($expr) {
+				if (empty($expr[1]) && parse_url($expr[2],PHP_URL_SCHEME)) {
+					$expr[2]=$this->esc($expr[2]);
+					return '<a href="'.$expr[2].'">'.$expr[2].'</a>';
+				}
+				return $expr[0];
+			},
+			$str
+		);
+	}
+
+	/**
+	*	Process code span
+	*	@return string
+	*	@param $str string
+	**/
+	protected function _code($str) {
+		return preg_replace_callback(
+			'/`` (.+?) ``|(?<!\\\\)`(.+?)(?!\\\\)`/',
+			function($expr) {
+				return '<code>'.
+					$this->esc(empty($expr[1])?$expr[2]:$expr[1]).'</code>';
+			},
+			$str
+		);
+	}
+
+	/**
+	*	Convert characters to HTML entities
+	*	@return string
+	*	@param $str string
+	**/
+	function esc($str) {
+		if (!$this->special)
+			$this->special=[
+				'...'=>'&hellip;',
+				'(tm)'=>'&trade;',
+				'(r)'=>'&reg;',
+				'(c)'=>'&copy;'
+			];
+		foreach ($this->special as $key=>$val)
+			$str=preg_replace('/'.preg_quote($key,'/').'/i',$val,$str);
+		return htmlspecialchars($str,ENT_COMPAT,
+			Base::instance()->ENCODING,FALSE);
+	}
+
+	/**
+	*	Reduce multiple line feeds
+	*	@return string
+	*	@param $str string
+	**/
+	protected function snip($str) {
+		return preg_replace('/(?:(?<=\n)\n+)|\n+$/',"\n",$str);
+	}
+
+	/**
+	*	Scan line for convertible spans
+	*	@return string
+	*	@param $str string
+	**/
+	function scan($str) {
+		$inline=['img','a','text','auto','code'];
+		foreach ($inline as $func)
+			$str=$this->{'_'.$func}($str);
+		return $str;
+	}
+
+	/**
+	*	Assemble blocks
+	*	@return string
+	*	@param $str string
+	**/
+	protected function build($str) {
+		if (!$this->blocks) {
+			// Regexes for capturing entire blocks
+			$this->blocks=[
+				'blockquote'=>'/^(?:\h?>\h?.*?(?:\n+|$))+/',
+				'pre'=>'/^(?:(?: {4}|\t).+?(?:\n+|$))+/',
+				'fence'=>'/^`{3}\h*(\w+)?.*?[^\n]*\n+(.+?)`{3}[^\n]*'.
+					'(?:\n+|$)/s',
+				'hr'=>'/^\h*[*_-](?:\h?[\*_-]){2,}\h*(?:\n+|$)/',
+				'atx'=>'/^\h*(#{1,6})\h?(.+?)\h*(?:#.*)?(?:\n+|$)/',
+				'setext'=>'/^\h*(.+?)\h*\n([=-])+\h*(?:\n+|$)/',
+				'li'=>'/^(?:(?:[*+-]|\d+\.)\h.+?(?:\n+|$)'.
+					'(?:(?: {4}|\t)+.+?(?:\n+|$))*)+/s',
+				'raw'=>'/^((?:<!--.+?-->|'.
+					'<(address|article|aside|audio|blockquote|canvas|dd|'.
+					'div|dl|fieldset|figcaption|figure|footer|form|h\d|'.
+					'header|hgroup|hr|noscript|object|ol|output|p|pre|'.
+					'section|table|tfoot|ul|video).*?'.
+					'(?:\/>|>(?:(?>[^><]+)|(?R))*<\/\2>))'.
+					'\h*(?:\n{2,}|\n*$)|<[\?%].+?[\?%]>\h*(?:\n?$|\n*))/s',
+				'p'=>'/^(.+?(?:\n{2,}|\n*$))/s'
+			];
+		}
+		// Treat lines with nothing but whitespaces as empty lines
+		$str=preg_replace('/\n\h+(?=\n)/',"\n",$str);
+		// Initialize block parser
+		$len=strlen($str);
+		$ptr=0;
+		$dst='';
+		// Main loop
+		while ($ptr<$len) {
+			if (preg_match('/^ {0,3}\[([^\[\]]+)\]:\s*<?(.*?)>?\s*'.
+				'(?:"([^\n]*)")?(?:\n+|$)/s',substr($str,$ptr),$match)) {
+				// Reference-style link; Backtrack
+				$ptr+=strlen($match[0]);
+				$tmp='';
+				// Catch line breaks in title attribute
+				$ref=preg_replace('/\h/','\s',preg_quote($match[1],'/'));
+				while ($dst!=$tmp) {
+					$dst=preg_replace_callback(
+						'/(?<!\\\\)\[('.$ref.')(?!\\\\)\]\s*\[\]|'.
+						'(!?)(?:\[([^\[\]]+)\]\s*)?'.
+						'(?<!\\\\)\[('.$ref.')(?!\\\\)\]/',
+						function($expr) use($match) {
+							return (empty($expr[2]))?
+								// Anchor
+								('<a href="'.$this->esc($match[2]).'"'.
+								(empty($match[3])?
+									'':
+									(' title="'.
+										$this->esc($match[3]).'"')).'>'.
+								// Link
+								$this->scan(
+									empty($expr[3])?
+										(empty($expr[1])?
+											$expr[4]:
+											$expr[1]):
+										$expr[3]
+								).'</a>'):
+								// Image
+								('<img src="'.$match[2].'"'.
+								(empty($expr[2])?
+									'':
+									(' alt="'.
+										$this->esc($expr[3]).'"')).
+								(empty($match[3])?
+									'':
+									(' title="'.
+										$this->esc($match[3]).'"')).
+								' />');
+						},
+						$tmp=$dst
+					);
+				}
+			}
+			else
+				foreach ($this->blocks as $func=>$regex)
+					if (preg_match($regex,substr($str,$ptr),$match)) {
+						$ptr+=strlen($match[0]);
+						$dst.=call_user_func_array(
+							[$this,'_'.$func],
+							count($match)>1?array_slice($match,1):$match
+						);
+						break;
+					}
+		}
+		return $dst;
+	}
+
+	/**
+	*	Render HTML equivalent of markdown
+	*	@return string
+	*	@param $txt string
+	**/
+	function convert($txt) {
+		$txt=preg_replace_callback(
+			'/(<code.*?>.+?<\/code>|'.
+			'<[^>\n]+>|\([^\n\)]+\)|"[^"\n]+")|'.
+			'\\\\(.)/s',
+			function($expr) {
+				// Process escaped characters
+				return empty($expr[1])?$expr[2]:$expr[1];
+			},
+			$this->build(preg_replace('/\r\n|\r/',"\n",$txt))
+		);
+		return $this->snip($txt);
+	}
+
+}

+ 111 - 0
lib/f3/matrix.php

@@ -0,0 +1,111 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Generic array utilities
+class Matrix extends Prefab {
+
+	/**
+	*	Retrieve values from a specified column of a multi-dimensional
+	*	array variable
+	*	@return array
+	*	@param $var array
+	*	@param $col mixed
+	**/
+	function pick(array $var,$col) {
+		return array_map(
+			function($row) use($col) {
+				return $row[$col];
+			},
+			$var
+		);
+	}
+
+	/**
+	*	Rotate a two-dimensional array variable
+	*	@return NULL
+	*	@param $var array
+	**/
+	function transpose(array &$var) {
+		$out=[];
+		foreach ($var as $keyx=>$cols)
+			foreach ($cols as $keyy=>$valy)
+				$out[$keyy][$keyx]=$valy;
+		$var=$out;
+	}
+
+	/**
+	*	Sort a multi-dimensional array variable on a specified column
+	*	@return bool
+	*	@param $var array
+	*	@param $col mixed
+	*	@param $order int
+	**/
+	function sort(array &$var,$col,$order=SORT_ASC) {
+		uasort(
+			$var,
+			function($val1,$val2) use($col,$order) {
+				list($v1,$v2)=[$val1[$col],$val2[$col]];
+				$out=is_numeric($v1) && is_numeric($v2)?
+					Base::instance()->sign($v1-$v2):strcmp($v1,$v2);
+				if ($order==SORT_DESC)
+					$out=-$out;
+				return $out;
+			}
+		);
+		$var=array_values($var);
+	}
+
+	/**
+	*	Change the key of a two-dimensional array element
+	*	@return NULL
+	*	@param $var array
+	*	@param $old string
+	*	@param $new string
+	**/
+	function changekey(array &$var,$old,$new) {
+		$keys=array_keys($var);
+		$vals=array_values($var);
+		$keys[array_search($old,$keys)]=$new;
+		$var=array_combine($keys,$vals);
+	}
+
+	/**
+	*	Return month calendar of specified date, with optional setting for
+	*	first day of week (0 for Sunday)
+	*	@return array
+	*	@param $date string
+	*	@param $first int
+	**/
+	function calendar($date='now',$first=0) {
+		$out=FALSE;
+		if (extension_loaded('calendar')) {
+			$parts=getdate(strtotime($date));
+			$days=cal_days_in_month(CAL_GREGORIAN,$parts['mon'],$parts['year']);
+			$ref=date('w',strtotime(date('Y-m',$parts[0]).'-01'))+(7-$first)%7;
+			$out=[];
+			for ($i=0;$i<$days;$i++)
+				$out[floor(($ref+$i)/7)][($ref+$i)%7]=$i+1;
+		}
+		return $out;
+	}
+
+}

+ 192 - 0
lib/f3/session.php

@@ -0,0 +1,192 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Cache-based session handler
+class Session {
+
+	protected
+		//! Session ID
+		$sid,
+		//! Anti-CSRF token
+		$_csrf,
+		//! User agent
+		$_agent,
+		//! IP,
+		$_ip,
+		//! Suspect callback
+		$onsuspect,
+		//! Cache instance
+		$_cache;
+
+	/**
+	*	Open session
+	*	@return TRUE
+	*	@param $path string
+	*	@param $name string
+	**/
+	function open($path,$name) {
+		return TRUE;
+	}
+
+	/**
+	*	Close session
+	*	@return TRUE
+	**/
+	function close() {
+		$this->sid=NULL;
+		return TRUE;
+	}
+
+	/**
+	*	Return session data in serialized format
+	*	@return string
+	*	@param $id string
+	**/
+	function read($id) {
+		$this->sid=$id;
+		if (!$data=$this->_cache->get($id.'.@'))
+			return '';
+		if ($data['ip']!=$this->_ip || $data['agent']!=$this->_agent) {
+			$fw=Base::instance();
+			if (!isset($this->onsuspect) ||
+				$fw->call($this->onsuspect,[$this,$id])===FALSE) {
+				//NB: `session_destroy` can't be called at that stage (`session_start` not completed)
+				$this->destroy($id);
+				$this->close();
+				unset($fw->{'COOKIE.'.session_name()});
+				$fw->error(403);
+			}
+		}
+		return $data['data'];
+	}
+
+	/**
+	*	Write session data
+	*	@return TRUE
+	*	@param $id string
+	*	@param $data string
+	**/
+	function write($id,$data) {
+		$fw=Base::instance();
+		$jar=$fw->JAR;
+		$this->_cache->set($id.'.@',
+			[
+				'data'=>$data,
+				'ip'=>$this->_ip,
+				'agent'=>$this->_agent,
+				'stamp'=>time()
+			],
+			$jar['expire']
+		);
+		return TRUE;
+	}
+
+	/**
+	*	Destroy session
+	*	@return TRUE
+	*	@param $id string
+	**/
+	function destroy($id) {
+		$this->_cache->clear($id.'.@');
+		return TRUE;
+	}
+
+	/**
+	*	Garbage collector
+	*	@return TRUE
+	*	@param $max int
+	**/
+	function cleanup($max) {
+		$this->_cache->reset('.@',$max);
+		return TRUE;
+	}
+
+	/**
+	 *	Return session id (if session has started)
+	 *	@return string|NULL
+	 **/
+	function sid() {
+		return $this->sid;
+	}
+
+	/**
+	 *	Return anti-CSRF token
+	 *	@return string
+	 **/
+	function csrf() {
+		return $this->_csrf;
+	}
+
+	/**
+	 *	Return IP address
+	 *	@return string
+	 **/
+	function ip() {
+		return $this->_ip;
+	}
+
+	/**
+	 *	Return Unix timestamp
+	 *	@return string|FALSE
+	 **/
+	function stamp() {
+		if (!$this->sid)
+			session_start();
+		return $this->_cache->exists($this->sid.'.@',$data)?
+			$data['stamp']:FALSE;
+	}
+
+	/**
+	 *	Return HTTP user agent
+	 *	@return string
+	 **/
+	function agent() {
+		return $this->_agent;
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $onsuspect callback
+	*	@param $key string
+	**/
+	function __construct($onsuspect=NULL,$key=NULL,$cache=null) {
+		$this->onsuspect=$onsuspect;
+		$this->_cache=$cache?:Cache::instance();
+		session_set_save_handler(
+			[$this,'open'],
+			[$this,'close'],
+			[$this,'read'],
+			[$this,'write'],
+			[$this,'destroy'],
+			[$this,'cleanup']
+		);
+		register_shutdown_function('session_commit');
+		$fw=\Base::instance();
+		$headers=$fw->HEADERS;
+		$this->_csrf=$fw->SEED.'.'.$fw->hash(mt_rand());
+		if ($key)
+			$fw->$key=$this->_csrf;
+		$this->_agent=isset($headers['User-Agent'])?$headers['User-Agent']:'';
+		$this->_ip=$fw->IP;
+	}
+
+}

+ 353 - 0
lib/f3/smtp.php

@@ -0,0 +1,353 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! SMTP plug-in
+class SMTP extends Magic {
+
+	//@{ Locale-specific error/exception messages
+	const
+		E_Header='%s: header is required',
+		E_Blank='Message must not be blank',
+		E_Attach='Attachment %s not found';
+	//@}
+
+	protected
+		//! Message properties
+		$headers,
+		//! E-mail attachments
+		$attachments,
+		//! SMTP host
+		$host,
+		//! SMTP port
+		$port,
+		//! TLS/SSL
+		$scheme,
+		//! User ID
+		$user,
+		//! Password
+		$pw,
+		//! TLS/SSL stream context
+		$context,
+		//! TCP/IP socket
+		$socket,
+		//! Server-client conversation
+		$log;
+
+	/**
+	*	Fix header
+	*	@return string
+	*	@param $key string
+	**/
+	protected function fixheader($key) {
+		return str_replace(' ','-',
+			ucwords(preg_replace('/[_-]/',' ',strtolower($key))));
+	}
+
+	/**
+	*	Return TRUE if header exists
+	*	@return bool
+	*	@param $key
+	**/
+	function exists($key) {
+		$key=$this->fixheader($key);
+		return isset($this->headers[$key]);
+	}
+
+	/**
+	*	Bind value to e-mail header
+	*	@return string
+	*	@param $key string
+	*	@param $val string
+	**/
+	function set($key,$val) {
+		$key=$this->fixheader($key);
+		return $this->headers[$key]=$val;
+	}
+
+	/**
+	*	Return value of e-mail header
+	*	@return string|NULL
+	*	@param $key string
+	**/
+	function &get($key) {
+		$key=$this->fixheader($key);
+		if (isset($this->headers[$key]))
+			$val=&$this->headers[$key];
+		else
+			$val=NULL;
+		return $val;
+	}
+
+	/**
+	*	Remove header
+	*	@return NULL
+	*	@param $key string
+	**/
+	function clear($key) {
+		$key=$this->fixheader($key);
+		unset($this->headers[$key]);
+	}
+
+	/**
+	*	Return client-server conversation history
+	*	@return string
+	**/
+	function log() {
+		return str_replace("\n",PHP_EOL,$this->log);
+	}
+
+	/**
+	*	Send SMTP command and record server response
+	*	@return string
+	*	@param $cmd string
+	*	@param $log bool|string
+	*	@param $mock bool
+	**/
+	protected function dialog($cmd=NULL,$log=TRUE,$mock=FALSE) {
+		$reply='';
+		if ($mock) {
+			$host=str_replace('ssl://','',$this->host);
+			switch ($cmd) {
+			case NULL:
+				$reply='220 '.$host.' ESMTP ready'."\n";
+				break;
+			case 'DATA':
+				$reply='354 Go ahead'."\n";
+				break;
+			case 'QUIT':
+				$reply='221 '.$host.' closing connection'."\n";
+				break;
+			default:
+				$reply='250 OK'."\n";
+				break;
+			}
+		}
+		else {
+			$socket=&$this->socket;
+			if ($cmd)
+				fputs($socket,$cmd."\r\n");
+			while (!feof($socket) && ($info=stream_get_meta_data($socket)) &&
+				!$info['timed_out'] && $str=fgets($socket,4096)) {
+				$reply.=$str;
+				if (preg_match('/(?:^|\n)\d{3} .+?\r\n/s',$reply))
+					break;
+			}
+		}
+		if ($log) {
+			if ($cmd)
+				$this->log.=$cmd."\n";
+			$this->log.=str_replace("\r",'',$reply);
+		}
+		return $reply;
+	}
+
+	/**
+	*	Add e-mail attachment
+	*	@return NULL
+	*	@param $file string
+	*	@param $alias string
+	*	@param $cid string
+	**/
+	function attach($file,$alias=NULL,$cid=NULL) {
+		if (!is_file($file))
+			user_error(sprintf(self::E_Attach,$file),E_USER_ERROR);
+		if ($alias)
+			$file=[$alias,$file];
+		$this->attachments[]=['filename'=>$file,'cid'=>$cid];
+	}
+
+	/**
+	*	Transmit message
+	*	@return bool
+	*	@param $message string
+	*	@param $log bool|string
+	*	@param $mock bool
+	**/
+	function send($message,$log=TRUE,$mock=FALSE) {
+		if ($this->scheme=='ssl' && !extension_loaded('openssl'))
+			return FALSE;
+		// Message should not be blank
+		if (!$message)
+			user_error(self::E_Blank,E_USER_ERROR);
+		$fw=Base::instance();
+		// Retrieve headers
+		$headers=$this->headers;
+		// Connect to the server
+		if (!$mock) {
+			$socket=&$this->socket;
+			$socket=@stream_socket_client($this->host.':'.$this->port,
+				$errno,$errstr,ini_get('default_socket_timeout'),
+				STREAM_CLIENT_CONNECT,$this->context);
+			if (!$socket) {
+				$fw->error(500,$errstr);
+				return FALSE;
+			}
+			stream_set_blocking($socket,TRUE);
+		}
+		// Get server's initial response
+		$this->dialog(NULL,TRUE,$mock);
+		// Announce presence
+		$reply=$this->dialog('EHLO '.$fw->HOST,$log,$mock);
+		if (strtolower($this->scheme)=='tls') {
+			$this->dialog('STARTTLS',$log,$mock);
+			if (!$mock)
+				stream_socket_enable_crypto(
+					$socket,TRUE,STREAM_CRYPTO_METHOD_TLS_CLIENT);
+			$reply=$this->dialog('EHLO '.$fw->HOST,$log,$mock);
+		}
+		$message=wordwrap($message,998);
+		if (preg_match('/8BITMIME/',$reply))
+			$headers['Content-Transfer-Encoding']='8bit';
+		else {
+			$headers['Content-Transfer-Encoding']='quoted-printable';
+			$message=preg_replace('/^\.(.+)/m',
+				'..$1',quoted_printable_encode($message));
+		}
+		if ($this->user && $this->pw && preg_match('/AUTH/',$reply)) {
+			// Authenticate
+			$this->dialog('AUTH LOGIN',$log,$mock);
+			$this->dialog(base64_encode($this->user),$log,$mock);
+			$reply=$this->dialog(base64_encode($this->pw),$log,$mock);
+			if (!preg_match('/^235\s.*/',$reply)) {
+				$this->dialog('QUIT',$log,$mock);
+				if (!$mock && $socket)
+					fclose($socket);
+				return FALSE;
+			}
+		}
+		if (empty($headers['Message-Id']))
+			$headers['Message-Id']='<'.uniqid('',TRUE).'@'.$this->host.'>';
+		if (empty($headers['Date']))
+			$headers['Date']=date('r');
+		// Required headers
+		$reqd=['From','To','Subject'];
+		foreach ($reqd as $id)
+			if (empty($headers[$id]))
+				user_error(sprintf(self::E_Header,$id),E_USER_ERROR);
+		$eol="\r\n";
+		// Stringify headers
+		foreach ($headers as $key=>&$val) {
+			if (in_array($key,['From','To','Cc','Bcc'])) {
+				$email='';
+				preg_match_all('/(?:".+?" )?(?:<.+?>|[^ ,]+)/',
+					$val,$matches,PREG_SET_ORDER);
+				foreach ($matches as $raw)
+					$email.=($email?', ':'').
+						(preg_match('/<.+?>/',$raw[0])?
+							$raw[0]:
+							('<'.$raw[0].'>'));
+				$val=$email;
+			}
+			unset($val);
+		}
+		// Start message dialog
+		$this->dialog('MAIL FROM: '.strstr($headers['From'],'<'),$log,$mock);
+		foreach ($fw->split($headers['To'].
+			(isset($headers['Cc'])?(';'.$headers['Cc']):'').
+			(isset($headers['Bcc'])?(';'.$headers['Bcc']):'')) as $dst) {
+			$this->dialog('RCPT TO: '.strstr($dst,'<'),$log,$mock);
+		}
+		$this->dialog('DATA',$log,$mock);
+		if ($this->attachments) {
+			// Replace Content-Type
+			$type=$headers['Content-Type'];
+			unset($headers['Content-Type']);
+			$enc=$headers['Content-Transfer-Encoding'];
+			unset($headers['Content-Transfer-Encoding']);
+			$hash=uniqid(NULL,TRUE);
+			// Send mail headers
+			$out='Content-Type: multipart/mixed; boundary="'.$hash.'"'.$eol;
+			foreach ($headers as $key=>$val)
+				if ($key!='Bcc')
+					$out.=$key.': '.$val.$eol;
+			$out.=$eol;
+			$out.='This is a multi-part message in MIME format'.$eol;
+			$out.=$eol;
+			$out.='--'.$hash.$eol;
+			$out.='Content-Type: '.$type.$eol;
+			$out.='Content-Transfer-Encoding: '.$enc.$eol;
+			$out.=$eol;
+			$out.=$message.$eol;
+			foreach ($this->attachments as $attachment) {
+				if (is_array($attachment['filename']))
+					list($alias,$file)=$attachment['filename'];
+				else
+					$alias=basename($file=$attachment['filename']);
+				$out.='--'.$hash.$eol;
+				$out.='Content-Type: application/octet-stream'.$eol;
+				$out.='Content-Transfer-Encoding: base64'.$eol;
+				if ($attachment['cid'])
+					$out.='Content-Id: '.$attachment['cid'].$eol;
+				$out.='Content-Disposition: attachment; '.
+					'filename="'.$alias.'"'.$eol;
+				$out.=$eol;
+				$out.=chunk_split(base64_encode(
+					file_get_contents($file))).$eol;
+			}
+			$out.=$eol;
+			$out.='--'.$hash.'--'.$eol;
+			$out.='.';
+			$this->dialog($out,preg_match('/verbose/i',$log),$mock);
+		}
+		else {
+			// Send mail headers
+			$out='';
+			foreach ($headers as $key=>$val)
+				if ($key!='Bcc')
+					$out.=$key.': '.$val.$eol;
+			$out.=$eol;
+			$out.=$message.$eol;
+			$out.='.';
+			// Send message
+			$this->dialog($out,preg_match('/verbose/i',$log),$mock);
+		}
+		$this->dialog('QUIT',$log,$mock);
+		if (!$mock && $socket)
+			fclose($socket);
+		return TRUE;
+	}
+
+	/**
+	*	Instantiate class
+	*	@param $host string
+	*	@param $port int
+	*	@param $scheme string
+	*	@param $user string
+	*	@param $pw string
+	*	@param $ctx resource
+	**/
+	function __construct(
+		$host='localhost',$port=25,$scheme=NULL,$user=NULL,$pw=NULL,$ctx=NULL) {
+		$this->headers=[
+			'MIME-Version'=>'1.0',
+			'Content-Type'=>'text/plain; '.
+				'charset='.Base::instance()->ENCODING
+		];
+		$this->host=strtolower((($this->scheme=strtolower($scheme))=='ssl'?
+			'ssl':'tcp').'://'.$host);
+		$this->port=$port;
+		$this->user=$user;
+		$this->pw=$pw;
+		$this->context=stream_context_create($ctx);
+	}
+
+}

+ 353 - 0
lib/f3/template.php

@@ -0,0 +1,353 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! XML-style template engine
+class Template extends Preview {
+
+	//@{ Error messages
+	const
+		E_Method='Call to undefined method %s()';
+	//@}
+
+	protected
+		//! Template tags
+		$tags,
+		//! Custom tag handlers
+		$custom=[];
+
+	/**
+	*	Template -set- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _set(array $node) {
+		$out='';
+		foreach ($node['@attrib'] as $key=>$val)
+			$out.='$'.$key.'='.
+				(preg_match('/\{\{(.+?)\}\}/',$val)?
+					$this->token($val):
+					Base::instance()->stringify($val)).'; ';
+		return '<?php '.$out.'?>';
+	}
+
+	/**
+	*	Template -include- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _include(array $node) {
+		$attrib=$node['@attrib'];
+		$hive=isset($attrib['with']) &&
+			($attrib['with']=$this->token($attrib['with'])) &&
+			preg_match_all('/(\w+)\h*=\h*(.+?)(?=,|$)/',
+				$attrib['with'],$pairs,PREG_SET_ORDER)?
+					('['.implode(',',
+						array_map(function($pair) {
+							return '\''.$pair[1].'\'=>'.
+								(preg_match('/^\'.*\'$/',$pair[2]) ||
+									preg_match('/\$/',$pair[2])?
+									$pair[2]:Base::instance()->stringify(
+										Base::instance()->cast($pair[2])));
+						},$pairs)).']+get_defined_vars()'):
+					'get_defined_vars()';
+		$ttl=isset($attrib['ttl'])?(int)$attrib['ttl']:0;
+		return
+			'<?php '.(isset($attrib['if'])?
+				('if ('.$this->token($attrib['if']).') '):'').
+				('echo $this->render('.
+					(preg_match('/^\{\{(.+?)\}\}$/',$attrib['href'])?
+						$this->token($attrib['href']):
+						Base::instance()->stringify($attrib['href'])).','.
+					'NULL,'.$hive.','.$ttl.'); ?>');
+	}
+
+	/**
+	*	Template -exclude- tag handler
+	*	@return string
+	**/
+	protected function _exclude() {
+		return '';
+	}
+
+	/**
+	*	Template -ignore- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _ignore(array $node) {
+		return $node[0];
+	}
+
+	/**
+	*	Template -loop- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _loop(array $node) {
+		$attrib=$node['@attrib'];
+		unset($node['@attrib']);
+		return
+			'<?php for ('.
+				$this->token($attrib['from']).';'.
+				$this->token($attrib['to']).';'.
+				$this->token($attrib['step']).'): ?>'.
+				$this->build($node).
+			'<?php endfor; ?>';
+	}
+
+	/**
+	*	Template -repeat- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _repeat(array $node) {
+		$attrib=$node['@attrib'];
+		unset($node['@attrib']);
+		return
+			'<?php '.
+				(isset($attrib['counter'])?
+					(($ctr=$this->token($attrib['counter'])).'=0; '):'').
+				'foreach (('.
+				$this->token($attrib['group']).'?:[]) as '.
+				(isset($attrib['key'])?
+					($this->token($attrib['key']).'=>'):'').
+				$this->token($attrib['value']).'):'.
+				(isset($ctr)?(' '.$ctr.'++;'):'').' ?>'.
+				$this->build($node).
+			'<?php endforeach; ?>';
+	}
+
+	/**
+	*	Template -check- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _check(array $node) {
+		$attrib=$node['@attrib'];
+		unset($node['@attrib']);
+		// Grab <true> and <false> blocks
+		foreach ($node as $pos=>$block)
+			if (isset($block['true']))
+				$true=[$pos,$block];
+			elseif (isset($block['false']))
+				$false=[$pos,$block];
+		if (isset($true,$false) && $true[0]>$false[0])
+			// Reverse <true> and <false> blocks
+			list($node[$true[0]],$node[$false[0]])=[$false[1],$true[1]];
+		return
+			'<?php if ('.$this->token($attrib['if']).'): ?>'.
+				$this->build($node).
+			'<?php endif; ?>';
+	}
+
+	/**
+	*	Template -true- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _true(array $node) {
+		return $this->build($node);
+	}
+
+	/**
+	*	Template -false- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _false(array $node) {
+		return '<?php else: ?>'.$this->build($node);
+	}
+
+	/**
+	*	Template -switch- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _switch(array $node) {
+		$attrib=$node['@attrib'];
+		unset($node['@attrib']);
+		foreach ($node as $pos=>$block)
+			if (is_string($block) && !preg_replace('/\s+/','',$block))
+				unset($node[$pos]);
+		return
+			'<?php switch ('.$this->token($attrib['expr']).'): ?>'.
+				$this->build($node).
+			'<?php endswitch; ?>';
+	}
+
+	/**
+	*	Template -case- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _case(array $node) {
+		$attrib=$node['@attrib'];
+		unset($node['@attrib']);
+		return
+			'<?php case '.(preg_match('/\{\{(.+?)\}\}/',$attrib['value'])?
+				$this->token($attrib['value']):
+				Base::instance()->stringify($attrib['value'])).': ?>'.
+				$this->build($node).
+			'<?php '.(isset($attrib['break'])?
+				'if ('.$this->token($attrib['break']).') ':'').
+				'break; ?>';
+	}
+
+	/**
+	*	Template -default- tag handler
+	*	@return string
+	*	@param $node array
+	**/
+	protected function _default(array $node) {
+		return
+			'<?php default: ?>'.
+				$this->build($node).
+			'<?php break; ?>';
+	}
+
+	/**
+	*	Assemble markup
+	*	@return string
+	*	@param $node array|string
+	**/
+	function build($node) {
+		if (is_string($node))
+			return parent::build($node);
+		$out='';
+		foreach ($node as $key=>$val)
+			$out.=is_int($key)?$this->build($val):$this->{'_'.$key}($val);
+		return $out;
+	}
+
+	/**
+	*	Extend template with custom tag
+	*	@return NULL
+	*	@param $tag string
+	*	@param $func callback
+	**/
+	function extend($tag,$func) {
+		$this->tags.='|'.$tag;
+		$this->custom['_'.$tag]=$func;
+	}
+
+	/**
+	*	Call custom tag handler
+	*	@return string|FALSE
+	*	@param $func string
+	*	@param $args array
+	**/
+	function __call($func,array $args) {
+		if ($func[0]=='_')
+			return call_user_func_array($this->custom[$func],$args);
+		if (method_exists($this,$func))
+			return call_user_func_array([$this,$func],$args);
+		user_error(sprintf(self::E_Method,$func),E_USER_ERROR);
+	}
+
+	/**
+	*	Parse string for template directives and tokens
+	*	@return array
+	*	@param $text string
+	**/
+	function parse($text) {
+		$text=parent::parse($text);
+		// Build tree structure
+		for ($ptr=0,$w=5,$len=strlen($text),$tree=[],$tmp='';$ptr<$len;)
+			if (preg_match('/^(.{0,'.$w.'}?)<(\/?)(?:F3:)?'.
+				'('.$this->tags.')\b((?:\s+[\w-.:@!]+'.
+				'(?:\h*=\h*(?:"(?:.*?)"|\'(?:.*?)\'))?|'.
+				'\h*\{\{.+?\}\})*)\h*(\/?)>/is',
+				substr($text,$ptr),$match)) {
+				if (strlen($tmp) || $match[1])
+					$tree[]=$tmp.$match[1];
+				// Element node
+				if ($match[2]) {
+					// Find matching start tag
+					$stack=[];
+					for($i=count($tree)-1;$i>=0;$i--) {
+						$item=$tree[$i];
+						if (is_array($item) &&
+							array_key_exists($match[3],$item) &&
+							!isset($item[$match[3]][0])) {
+							// Start tag found
+							$tree[$i][$match[3]]+=array_reverse($stack);
+							$tree=array_slice($tree,0,$i+1);
+							break;
+						}
+						else $stack[]=$item;
+					}
+				}
+				else {
+					// Start tag
+					$node=&$tree[][$match[3]];
+					$node=[];
+					if ($match[4]) {
+						// Process attributes
+						preg_match_all(
+							'/(?:(\{\{.+?\}\})|([^\s\/"\'=]+))'.
+							'\h*(?:=\h*(?:"(.*?)"|\'(.*?)\'))?/s',
+							$match[4],$attr,PREG_SET_ORDER);
+						foreach ($attr as $kv)
+							if (!empty($kv[1]) && !isset($kv[3]) && !isset($kv[4]))
+								$node['@attrib'][]=$kv[1];
+							else
+								$node['@attrib'][$kv[1]?:$kv[2]]=
+									(isset($kv[3]) && $kv[3]!==''?
+										$kv[3]:
+										(isset($kv[4]) && $kv[4]!==''?
+											$kv[4]:NULL));
+					}
+				}
+				$tmp='';
+				$ptr+=strlen($match[0]);
+				$w=5;
+			}
+			else {
+				// Text node
+				$tmp.=substr($text,$ptr,$w);
+				$ptr+=$w;
+				if ($w<50)
+					$w++;
+			}
+		if (strlen($tmp))
+			// Append trailing text
+			$tree[]=$tmp;
+		// Break references
+		unset($node);
+		return $tree;
+	}
+
+	/**
+	*	Class constructor
+	*	return object
+	**/
+	function __construct() {
+		$ref=new ReflectionClass(get_called_class());
+		$this->tags='';
+		foreach ($ref->getmethods() as $method)
+			if (preg_match('/^_(?=[[:alpha:]])/',$method->name))
+				$this->tags.=(strlen($this->tags)?'|':'').
+					substr($method->name,1);
+		parent::__construct();
+	}
+
+}

+ 96 - 0
lib/f3/test.php

@@ -0,0 +1,96 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Unit test kit
+class Test {
+
+	//@{ Reporting level
+	const
+		FLAG_False=0,
+		FLAG_True=1,
+		FLAG_Both=2;
+	//@}
+
+	protected
+		//! Test results
+		$data=[],
+		//! Success indicator
+		$passed=TRUE;
+
+	/**
+	*	Return test results
+	*	@return array
+	**/
+	function results() {
+		return $this->data;
+	}
+
+	/**
+	*	Return FALSE if at least one test case fails
+	*	@return bool
+	**/
+	function passed() {
+		return $this->passed;
+	}
+
+	/**
+	*	Evaluate condition and save test result
+	*	@return object
+	*	@param $cond bool
+	*	@param $text string
+	**/
+	function expect($cond,$text=NULL) {
+		$out=(bool)$cond;
+		if ($this->level==$out || $this->level==self::FLAG_Both) {
+			$data=['status'=>$out,'text'=>$text,'source'=>NULL];
+			foreach (debug_backtrace() as $frame)
+				if (isset($frame['file'])) {
+					$data['source']=Base::instance()->
+						fixslashes($frame['file']).':'.$frame['line'];
+					break;
+				}
+			$this->data[]=$data;
+		}
+		if (!$out && $this->passed)
+			$this->passed=FALSE;
+		return $this;
+	}
+
+	/**
+	*	Append message to test results
+	*	@return NULL
+	*	@param $text string
+	**/
+	function message($text) {
+		$this->expect(TRUE,$text);
+	}
+
+	/**
+	*	Class constructor
+	*	@return NULL
+	*	@param $level int
+	**/
+	function __construct($level=self::FLAG_Both) {
+		$this->level=$level;
+	}
+
+}

+ 199 - 0
lib/f3/utf.php

@@ -0,0 +1,199 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Unicode string manager
+class UTF extends Prefab {
+
+	/**
+	*	Get string length
+	*	@return int
+	*	@param $str string
+	**/
+	function strlen($str) {
+		preg_match_all('/./us',$str,$parts);
+		return count($parts[0]);
+	}
+
+	/**
+	*	Reverse a string
+	*	@return string
+	*	@param $str string
+	**/
+	function strrev($str) {
+		preg_match_all('/./us',$str,$parts);
+		return implode('',array_reverse($parts[0]));
+	}
+
+	/**
+	*	Find position of first occurrence of a string (case-insensitive)
+	*	@return int|FALSE
+	*	@param $stack string
+	*	@param $needle string
+	*	@param $ofs int
+	**/
+	function stripos($stack,$needle,$ofs=0) {
+		return $this->strpos($stack,$needle,$ofs,TRUE);
+	}
+
+	/**
+	*	Find position of first occurrence of a string
+	*	@return int|FALSE
+	*	@param $stack string
+	*	@param $needle string
+	*	@param $ofs int
+	*	@param $case bool
+	**/
+	function strpos($stack,$needle,$ofs=0,$case=FALSE) {
+		return preg_match('/^(.{'.$ofs.'}.*?)'.
+			preg_quote($needle,'/').'/us'.($case?'i':''),$stack,$match)?
+			$this->strlen($match[1]):FALSE;
+	}
+
+	/**
+	*	Returns part of haystack string from the first occurrence of
+	*	needle to the end of haystack (case-insensitive)
+	*	@return string|FALSE
+	*	@param $stack string
+	*	@param $needle string
+	*	@param $before bool
+	**/
+	function stristr($stack,$needle,$before=FALSE) {
+		return $this->strstr($stack,$needle,$before,TRUE);
+	}
+
+	/**
+	*	Returns part of haystack string from the first occurrence of
+	*	needle to the end of haystack
+	*	@return string|FALSE
+	*	@param $stack string
+	*	@param $needle string
+	*	@param $before bool
+	*	@param $case bool
+	**/
+	function strstr($stack,$needle,$before=FALSE,$case=FALSE) {
+		if (!$needle)
+			return FALSE;
+		preg_match('/^(.*?)'.preg_quote($needle,'/').'/us'.($case?'i':''),
+			$stack,$match);
+		return isset($match[1])?
+			($before?
+				$match[1]:
+				$this->substr($stack,$this->strlen($match[1]))):
+			FALSE;
+	}
+
+	/**
+	*	Return part of a string
+	*	@return string|FALSE
+	*	@param $str string
+	*	@param $start int
+	*	@param $len int
+	**/
+	function substr($str,$start,$len=0) {
+		if ($start<0)
+			$start=$this->strlen($str)+$start;
+		if (!$len)
+			$len=$this->strlen($str)-$start;
+		return preg_match('/^.{'.$start.'}(.{0,'.$len.'})/us',$str,$match)?
+			$match[1]:FALSE;
+	}
+
+	/**
+	*	Count the number of substring occurrences
+	*	@return int
+	*	@param $stack string
+	*	@param $needle string
+	**/
+	function substr_count($stack,$needle) {
+		preg_match_all('/'.preg_quote($needle,'/').'/us',$stack,
+			$matches,PREG_SET_ORDER);
+		return count($matches);
+	}
+
+	/**
+	*	Strip whitespaces from the beginning of a string
+	*	@return string
+	*	@param $str string
+	**/
+	function ltrim($str) {
+		return preg_replace('/^[\pZ\pC]+/u','',$str);
+	}
+
+	/**
+	*	Strip whitespaces from the end of a string
+	*	@return string
+	*	@param $str string
+	**/
+	function rtrim($str) {
+		return preg_replace('/[\pZ\pC]+$/u','',$str);
+	}
+
+	/**
+	*	Strip whitespaces from the beginning and end of a string
+	*	@return string
+	*	@param $str string
+	**/
+	function trim($str) {
+		return preg_replace('/^[\pZ\pC]+|[\pZ\pC]+$/u','',$str);
+	}
+
+	/**
+	*	Return UTF-8 byte order mark
+	*	@return string
+	**/
+	function bom() {
+		return chr(0xef).chr(0xbb).chr(0xbf);
+	}
+
+	/**
+	*	Convert code points to Unicode symbols
+	*	@return string
+	*	@param $str string
+	**/
+	function translate($str) {
+		return html_entity_decode(
+			preg_replace('/\\\\u([[:xdigit:]]+)/i','&#x\1;',$str));
+	}
+
+	/**
+	*	Translate emoji tokens to Unicode font-supported symbols
+	*	@return string
+	*	@param $str string
+	**/
+	function emojify($str) {
+		$map=[
+			':('=>'\u2639', // frown
+			':)'=>'\u263a', // smile
+			'<3'=>'\u2665', // heart
+			':D'=>'\u1f603', // grin
+			'XD'=>'\u1f606', // laugh
+			';)'=>'\u1f609', // wink
+			':P'=>'\u1f60b', // tongue
+			':,'=>'\u1f60f', // think
+			':/'=>'\u1f623', // skeptic
+			'8O'=>'\u1f632', // oops
+		]+Base::instance()->EMOJI;
+		return $this->translate(str_replace(array_keys($map),
+			array_values($map),$str));
+	}
+
+}

+ 932 - 0
lib/f3/web.php

@@ -0,0 +1,932 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+//! Wrapper for various HTTP utilities
+class Web extends Prefab {
+
+	//@{ Error messages
+	const
+		E_Request='No suitable HTTP request engine found';
+	//@}
+
+	protected
+		//! HTTP request engine
+		$wrapper;
+
+	/**
+	*	Detect MIME type using file extension
+	*	@return string
+	*	@param $file string
+	**/
+	function mime($file) {
+		if (preg_match('/\w+$/',$file,$ext)) {
+			$map=[
+				'au'=>'audio/basic',
+				'avi'=>'video/avi',
+				'bmp'=>'image/bmp',
+				'bz2'=>'application/x-bzip2',
+				'css'=>'text/css',
+				'dtd'=>'application/xml-dtd',
+				'doc'=>'application/msword',
+				'gif'=>'image/gif',
+				'gz'=>'application/x-gzip',
+				'hqx'=>'application/mac-binhex40',
+				'html?'=>'text/html',
+				'jar'=>'application/java-archive',
+				'jpe?g|jfif?'=>'image/jpeg',
+				'js'=>'application/x-javascript',
+				'midi'=>'audio/x-midi',
+				'mp3'=>'audio/mpeg',
+				'mpe?g'=>'video/mpeg',
+				'ogg'=>'audio/vorbis',
+				'pdf'=>'application/pdf',
+				'png'=>'image/png',
+				'ppt'=>'application/vnd.ms-powerpoint',
+				'ps'=>'application/postscript',
+				'qt'=>'video/quicktime',
+				'ram?'=>'audio/x-pn-realaudio',
+				'rdf'=>'application/rdf',
+				'rtf'=>'application/rtf',
+				'sgml?'=>'text/sgml',
+				'sit'=>'application/x-stuffit',
+				'svg'=>'image/svg+xml',
+				'swf'=>'application/x-shockwave-flash',
+				'tgz'=>'application/x-tar',
+				'tiff'=>'image/tiff',
+				'txt'=>'text/plain',
+				'wav'=>'audio/wav',
+				'xls'=>'application/vnd.ms-excel',
+				'xml'=>'application/xml',
+				'zip'=>'application/x-zip-compressed'
+			];
+			foreach ($map as $key=>$val)
+				if (preg_match('/'.$key.'/',strtolower($ext[0])))
+					return $val;
+		}
+		return 'application/octet-stream';
+	}
+
+	/**
+	*	Return the MIME types stated in the HTTP Accept header as an array;
+	*	If a list of MIME types is specified, return the best match; or
+	*	FALSE if none found
+	*	@return array|string|FALSE
+	*	@param $list string|array
+	**/
+	function acceptable($list=NULL) {
+		$accept=[];
+		foreach (explode(',',str_replace(' ','',@$_SERVER['HTTP_ACCEPT']))
+			as $mime)
+			if (preg_match('/(.+?)(?:;q=([\d\.]+)|$)/',$mime,$parts))
+				$accept[$parts[1]]=isset($parts[2])?$parts[2]:1;
+		if (!$accept)
+			$accept['*/*']=1;
+		else {
+			krsort($accept);
+			arsort($accept);
+		}
+		if ($list) {
+			if (is_string($list))
+				$list=explode(',',$list);
+			foreach ($accept as $mime=>$q)
+				if ($q && $out=preg_grep('/'.
+					str_replace('\*','.*',preg_quote($mime,'/')).'/',$list))
+					return current($out);
+			return FALSE;
+		}
+		return $accept;
+	}
+
+	/**
+	*	Transmit file to HTTP client; Return file size if successful,
+	*	FALSE otherwise
+	*	@return int|FALSE
+	*	@param $file string
+	*	@param $mime string
+	*	@param $kbps int
+	*	@param $force bool
+	*	@param $name string
+	*	@param $flush bool
+	**/
+	function send($file,$mime=NULL,$kbps=0,$force=TRUE,$name=NULL,$flush=TRUE) {
+		if (!is_file($file))
+			return FALSE;
+		$size=filesize($file);
+		if (PHP_SAPI!='cli') {
+			header('Content-Type: '.($mime?:$this->mime($file)));
+			if ($force)
+				header('Content-Disposition: attachment; '.
+					'filename="'.($name!==NULL?$name:basename($file)).'"');
+			header('Accept-Ranges: bytes');
+			header('Content-Length: '.$size);
+			header('X-Powered-By: '.Base::instance()->PACKAGE);
+		}
+		if (!$kbps && $flush) {
+			while (ob_get_level())
+				ob_end_clean();
+			readfile($file);
+		}
+		else {
+			$ctr=0;
+			$handle=fopen($file,'rb');
+			$start=microtime(TRUE);
+			while (!feof($handle) &&
+				($info=stream_get_meta_data($handle)) &&
+				!$info['timed_out'] && !connection_aborted()) {
+				if ($kbps) {
+					// Throttle output
+					$ctr++;
+					if ($ctr/$kbps>$elapsed=microtime(TRUE)-$start)
+						usleep(1e6*($ctr/$kbps-$elapsed));
+				}
+				// Send 1KiB and reset timer
+				echo fread($handle,1024);
+				if ($flush) {
+					ob_flush();
+					flush();
+				}
+			}
+			fclose($handle);
+		}
+		return $size;
+	}
+
+	/**
+	*	Receive file(s) from HTTP client
+	*	@return array|bool
+	*	@param $func callback
+	*	@param $overwrite bool
+	*	@param $slug callback|bool
+	**/
+	function receive($func=NULL,$overwrite=FALSE,$slug=TRUE) {
+		$fw=Base::instance();
+		$dir=$fw->UPLOADS;
+		if (!is_dir($dir))
+			mkdir($dir,Base::MODE,TRUE);
+		if ($fw->VERB=='PUT') {
+			$tmp=$fw->TEMP.$fw->SEED.'.'.$fw->hash(uniqid());
+			if (!$fw->RAW)
+				$fw->write($tmp,$fw->BODY);
+			else {
+				$src=@fopen('php://input','r');
+				$dst=@fopen($tmp,'w');
+				if (!$src || !$dst)
+					return FALSE;
+				while (!feof($src) &&
+					($info=stream_get_meta_data($src)) &&
+					!$info['timed_out'] && $str=fgets($src,4096))
+					fputs($dst,$str,strlen($str));
+				fclose($dst);
+				fclose($src);
+			}
+			$base=basename($fw->URI);
+			$file=[
+				'name'=>$dir.
+					($slug && preg_match('/(.+?)(\.\w+)?$/',$base,$parts)?
+						(is_callable($slug)?
+							$slug($base):
+							($this->slug($parts[1]).
+								(isset($parts[2])?$parts[2]:''))):
+						$base),
+				'tmp_name'=>$tmp,
+				'type'=>$this->mime($base),
+				'size'=>filesize($tmp)
+			];
+			return (!file_exists($file['name']) || $overwrite) &&
+				(!$func || $fw->call($func,[$file])!==FALSE) &&
+				rename($tmp,$file['name']);
+		}
+		$fetch=function($arr) use(&$fetch) {
+			if (!is_array($arr))
+				return [$arr];
+			$data=[];
+			foreach($arr as $k=>$sub)
+				$data=array_merge($data,$fetch($sub));
+			return $data;
+		};
+		$out=[];
+		foreach ($_FILES as $name=>$item) {
+			$files=[];
+			foreach ($item as $k=>$mix)
+				foreach ($fetch($mix) as $i=>$val)
+					$files[$i][$k]=$val;
+			foreach ($files as $file) {
+				if (empty($file['name']))
+					continue;
+				$base=basename($file['name']);
+				$file['name']=$dir.
+					($slug && preg_match('/(.+?)(\.\w+)?$/',$base,$parts)?
+						(is_callable($slug)?
+							$slug($base,$name):
+							($this->slug($parts[1]).
+								(isset($parts[2])?$parts[2]:''))):
+						$base);
+				$out[$file['name']]=!$file['error'] &&
+					(!file_exists($file['name']) || $overwrite) &&
+					(!$func || $fw->call($func,[$file,$name])!==FALSE) &&
+					move_uploaded_file($file['tmp_name'],$file['name']);
+			}
+		}
+		return $out;
+	}
+
+	/**
+	*	Return upload progress in bytes, FALSE on failure
+	*	@return int|FALSE
+	*	@param $id string
+	**/
+	function progress($id) {
+		// ID returned by session.upload_progress.name
+		return ini_get('session.upload_progress.enabled') &&
+			isset($_SESSION[$id]['bytes_processed'])?
+				$_SESSION[$id]['bytes_processed']:FALSE;
+	}
+
+	/**
+	*	HTTP request via cURL
+	*	@return array
+	*	@param $url string
+	*	@param $options array
+	**/
+	protected function _curl($url,$options) {
+		$curl=curl_init($url);
+		if (!ini_get('open_basedir'))
+			curl_setopt($curl,CURLOPT_FOLLOWLOCATION,
+				$options['follow_location']);
+		curl_setopt($curl,CURLOPT_MAXREDIRS,
+			$options['max_redirects']);
+		curl_setopt($curl,CURLOPT_PROTOCOLS,CURLPROTO_HTTP|CURLPROTO_HTTPS);
+		curl_setopt($curl,CURLOPT_REDIR_PROTOCOLS,CURLPROTO_HTTP|CURLPROTO_HTTPS);
+		curl_setopt($curl,CURLOPT_CUSTOMREQUEST,$options['method']);
+		if (isset($options['header']))
+			curl_setopt($curl,CURLOPT_HTTPHEADER,$options['header']);
+		if (isset($options['content']))
+			curl_setopt($curl,CURLOPT_POSTFIELDS,$options['content']);
+		if (isset($options['proxy']))
+			curl_setopt($curl,CURLOPT_PROXY,$options['proxy']);
+		curl_setopt($curl,CURLOPT_ENCODING,'gzip,deflate');
+		$timeout=isset($options['timeout'])?
+			$options['timeout']:
+			ini_get('default_socket_timeout');
+		curl_setopt($curl,CURLOPT_CONNECTTIMEOUT,$timeout);
+		curl_setopt($curl,CURLOPT_TIMEOUT,$timeout);
+		$headers=[];
+		curl_setopt($curl,CURLOPT_HEADERFUNCTION,
+			// Callback for response headers
+			function($curl,$line) use(&$headers) {
+				if ($trim=trim($line))
+					$headers[]=$trim;
+				return strlen($line);
+			}
+		);
+		curl_setopt($curl,CURLOPT_SSL_VERIFYHOST,2);
+		curl_setopt($curl,CURLOPT_SSL_VERIFYPEER,FALSE);
+		ob_start();
+		curl_exec($curl);
+		$err=curl_error($curl);
+		curl_close($curl);
+		$body=ob_get_clean();
+		if (!$err &&
+			$options['follow_location'] &&
+			preg_grep('/HTTP\/1\.\d 3\d{2}/',$headers) &&
+			preg_match('/^Location: (.+)$/m',implode(PHP_EOL,$headers),$loc)) {
+			$options['max_redirects']--;
+			if($loc[1][0] == '/') {
+				$parts=parse_url($url);
+				$loc[1]=$parts['scheme'].'://'.$parts['host'].
+					((isset($parts['port']) && !in_array($parts['port'],[80,443]))
+						?':'.$parts['port']:'').$loc[1];
+			}
+			return $this->request($loc[1],$options);
+		}
+		return [
+			'body'=>$body,
+			'headers'=>$headers,
+			'engine'=>'cURL',
+			'cached'=>FALSE,
+			'error'=>$err
+		];
+	}
+
+	/**
+	*	HTTP request via PHP stream wrapper
+	*	@return array
+	*	@param $url string
+	*	@param $options array
+	**/
+	protected function _stream($url,$options) {
+		$eol="\r\n";
+		if (isset($options['proxy'])) {
+			$options['proxy']=preg_replace('/https?/i','tcp',$options['proxy']);
+			$options['request_fulluri']=true;
+			if (preg_match('/socks4?/i',$options['proxy']))
+				return $this->_socket($url,$options);
+		}
+		$options['header']=implode($eol,$options['header']);
+		$body=@file_get_contents($url,FALSE,
+			stream_context_create(['http'=>$options]));
+		$headers=isset($http_response_header)?
+			$http_response_header:[];
+		$err='';
+		if (is_string($body)) {
+			$match=NULL;
+			foreach ($headers as $header)
+				if (preg_match('/Content-Encoding: (.+)/',$header,$match))
+					break;
+			if ($match)
+				switch ($match[1]) {
+					case 'gzip':
+						$body=gzdecode($body);
+						break;
+					case 'deflate':
+						$body=gzuncompress($body);
+						break;
+				}
+		}
+		else {
+			$tmp=error_get_last();
+			$err=$tmp['message'];
+		}
+		return [
+			'body'=>$body,
+			'headers'=>$headers,
+			'engine'=>'stream',
+			'cached'=>FALSE,
+			'error'=>$err
+		];
+	}
+
+	/**
+	*	HTTP request via low-level TCP/IP socket
+	*	@return array
+	*	@param $url string
+	*	@param $options array
+	**/
+	protected function _socket($url,$options) {
+		$eol="\r\n";
+		$headers=[];
+		$body='';
+		$parts=parse_url($url);
+		$hostname=$parts['host'];
+		$proxy=false;
+		if ($parts['scheme']=='https')
+			$parts['host']='ssl://'.$parts['host'];
+		if (empty($parts['port']))
+			$parts['port']=$parts['scheme']=='https'?443:80;
+		if (empty($parts['path']))
+			$parts['path']='/';
+		if (empty($parts['query']))
+			$parts['query']='';
+		if (isset($options['proxy'])) {
+			$req=$url;
+			$pp=parse_url($options['proxy']);
+			$proxy=$pp['scheme'];
+			if ($pp['scheme']=='https')
+				$pp['host']='ssl://'.$pp['host'];
+			if (empty($pp['port']))
+				$pp['port']=$pp['scheme']=='https'?443:80;
+			$socket=@fsockopen($pp['host'],$pp['port'],$code,$err);
+		} else {
+			$req=$parts['path'].($parts['query']?('?'.$parts['query']):'');
+			$socket=@fsockopen($parts['host'],$parts['port'],$code,$err);
+		}
+		if ($socket) {
+			stream_set_blocking($socket,TRUE);
+			stream_set_timeout($socket,isset($options['timeout'])?
+				$options['timeout']:ini_get('default_socket_timeout'));
+			if ($proxy=='socks4') {
+				// SOCKS4; http://en.wikipedia.org/wiki/SOCKS#Protocol
+				$packet="\x04\x01".pack("n", $parts['port']).
+					pack("H*",dechex(ip2long(gethostbyname($hostname))))."\0";
+				fputs($socket, $packet, strlen($packet));
+				$response=fread($socket, 9);
+				if (strlen($response)==8 && (ord($response[0])==0 || ord($response[0])==4)
+					&& ord($response[1])==90) {
+					$options['header'][]='Host: '.$hostname;
+				} else
+					$err='Socket Status '.ord($response[1]);
+			}
+			fputs($socket,$options['method'].' '.$req.' HTTP/1.0'.$eol);
+			fputs($socket,implode($eol,$options['header']).$eol.$eol);
+			if (isset($options['content']))
+				fputs($socket,$options['content'].$eol);
+			// Get response
+			$content='';
+			while (!feof($socket) &&
+				($info=stream_get_meta_data($socket)) &&
+				!$info['timed_out'] && !connection_aborted() &&
+				$str=fgets($socket,4096))
+				$content.=$str;
+			fclose($socket);
+			$html=explode($eol.$eol,$content,2);
+			$body=isset($html[1])?$html[1]:'';
+			$headers=array_merge($headers,$current=explode($eol,$html[0]));
+			$match=NULL;
+			foreach ($current as $header)
+				if (preg_match('/Content-Encoding: (.+)/',$header,$match))
+					break;
+			if ($match)
+				switch ($match[1]) {
+					case 'gzip':
+						$body=gzdecode($body);
+						break;
+					case 'deflate':
+						$body=gzuncompress($body);
+						break;
+				}
+			if ($options['follow_location'] &&
+				preg_grep('/HTTP\/1\.\d 3\d{2}/',$headers) &&
+				preg_match('/Location: (.+?)'.preg_quote($eol).'/',
+				$html[0],$loc)) {
+				$options['max_redirects']--;
+				return $this->request($loc[1],$options);
+			}
+		}
+		return [
+			'body'=>$body,
+			'headers'=>$headers,
+			'engine'=>'socket',
+			'cached'=>FALSE,
+			'error'=>$err
+		];
+	}
+
+	/**
+	*	Specify the HTTP request engine to use; If not available,
+	*	fall back to an applicable substitute
+	*	@return string
+	*	@param $arg string
+	**/
+	function engine($arg='curl') {
+		$arg=strtolower($arg);
+		$flags=[
+			'curl'=>extension_loaded('curl'),
+			'stream'=>ini_get('allow_url_fopen'),
+			'socket'=>function_exists('fsockopen')
+		];
+		if ($flags[$arg])
+			return $this->wrapper=$arg;
+		foreach ($flags as $key=>$val)
+			if ($val)
+				return $this->wrapper=$key;
+		user_error(self::E_Request,E_USER_ERROR);
+	}
+
+	/**
+	*	Replace old headers with new elements
+	*	@return NULL
+	*	@param $old array
+	*	@param $new string|array
+	**/
+	function subst(array &$old,$new) {
+		if (is_string($new))
+			$new=[$new];
+		foreach ($new as $hdr) {
+			$old=preg_grep('/'.preg_quote(strstr($hdr,':',TRUE),'/').':.+/',
+				$old,PREG_GREP_INVERT);
+			array_push($old,$hdr);
+		}
+	}
+
+	/**
+	*	Submit HTTP request; Use HTTP context options (described in
+	*	http://www.php.net/manual/en/context.http.php) if specified;
+	*	Cache the page as instructed by remote server
+	*	@return array|FALSE
+	*	@param $url string
+	*	@param $options array
+	**/
+	function request($url,array $options=NULL) {
+		$fw=Base::instance();
+		$parts=parse_url($url);
+		if (empty($parts['scheme'])) {
+			// Local URL
+			$url=$fw->SCHEME.'://'.$fw->HOST.
+				(in_array($fw->PORT,[80,443])?'':(':'.$fw->PORT)).
+				($url[0]!='/'?($fw->BASE.'/'):'').$url;
+			$parts=parse_url($url);
+		}
+		elseif (!preg_match('/https?/',$parts['scheme']))
+			return FALSE;
+		if (!is_array($options))
+			$options=[];
+		if (empty($options['header']))
+			$options['header']=[];
+		elseif (is_string($options['header']))
+			$options['header']=[$options['header']];
+		if (!$this->wrapper)
+			$this->engine();
+		if ($this->wrapper!='stream') {
+			// PHP streams can't cope with redirects when Host header is set
+			$this->subst($options['header'],'Host: '.$parts['host']);
+		}
+		$this->subst($options['header'],
+			[
+				'Accept-Encoding: gzip,deflate',
+				'User-Agent: '.(isset($options['user_agent'])?
+					$options['user_agent']:
+					'Mozilla/5.0 (compatible; '.php_uname('s').')'),
+				'Connection: close'
+			]
+		);
+		if (isset($options['content']) && is_string($options['content'])) {
+			if ($options['method']=='POST' &&
+				!preg_grep('/^Content-Type:/',$options['header']))
+				$this->subst($options['header'],
+					'Content-Type: application/x-www-form-urlencoded');
+			$this->subst($options['header'],
+				'Content-Length: '.strlen($options['content']));
+		}
+		if (isset($parts['user'],$parts['pass']))
+			$this->subst($options['header'],
+				'Authorization: Basic '.
+					base64_encode($parts['user'].':'.$parts['pass'])
+			);
+		$options+=[
+			'method'=>'GET',
+			'header'=>$options['header'],
+			'follow_location'=>TRUE,
+			'max_redirects'=>20,
+			'ignore_errors'=>FALSE
+		];
+		$eol="\r\n";
+		if ($fw->CACHE &&
+			preg_match('/GET|HEAD/',$options['method'])) {
+			$cache=Cache::instance();
+			if ($cache->exists(
+				$hash=$fw->hash($options['method'].' '.$url).'.url',$data)) {
+				if (preg_match('/Last-Modified: (.+?)'.preg_quote($eol).'/',
+					implode($eol,$data['headers']),$mod))
+					$this->subst($options['header'],
+						'If-Modified-Since: '.$mod[1]);
+			}
+		}
+		$result=$this->{'_'.$this->wrapper}($url,$options);
+		if ($result && isset($cache)) {
+			if (preg_match('/HTTP\/1\.\d 304/',
+				implode($eol,$result['headers']))) {
+				$result=$cache->get($hash);
+				$result['cached']=TRUE;
+			}
+			elseif (preg_match('/Cache-Control:(?:.*)max-age=(\d+)(?:,?.*'.
+				preg_quote($eol).')/',implode($eol,$result['headers']),$exp))
+				$cache->set($hash,$result,$exp[1]);
+		}
+		$req=[$options['method'].' '.$url];
+		foreach ($options['header'] as $header)
+			array_push($req,$header);
+		return array_merge(['request'=>$req],$result);
+	}
+
+	/**
+	*	Strip Javascript/CSS files of extraneous whitespaces and comments;
+	*	Return combined output as a minified string
+	*	@return string
+	*	@param $files string|array
+	*	@param $mime string
+	*	@param $header bool
+	*	@param $path string
+	**/
+	function minify($files,$mime=NULL,$header=TRUE,$path=NULL) {
+		$fw=Base::instance();
+		if (is_string($files))
+			$files=$fw->split($files);
+		if (!$mime)
+			$mime=$this->mime($files[0]);
+		preg_match('/\w+$/',$files[0],$ext);
+		$cache=Cache::instance();
+		$dst='';
+		if (!isset($path))
+			$path=$fw->UI.';./';
+		foreach ($fw->split($path,FALSE) as $dir)
+			foreach ($files as $file)
+				if (is_file($save=$fw->fixslashes($dir.$file)) &&
+					is_bool(strpos($save,'../')) &&
+					preg_match('/\.(css|js)$/i',$file)) {
+					if ($fw->CACHE &&
+						($cached=$cache->exists(
+							$hash=$fw->hash($save).'.'.$ext[0],$data)) &&
+						$cached[0]>filemtime($save))
+						$dst.=$data;
+					else {
+						$data='';
+						$src=$fw->read($save);
+						for ($ptr=0,$len=strlen($src);$ptr<$len;) {
+							if (preg_match('/^@import\h+url'.
+								'\(\h*([\'"])((?!(?:https?:)?\/\/).+?)\1\h*\)[^;]*;/',
+								substr($src,$ptr),$parts)) {
+								$path=dirname($file);
+								$data.=$this->minify(
+									($path?($path.'/'):'').$parts[2],
+									$mime,$header
+								);
+								$ptr+=strlen($parts[0]);
+								continue;
+							}
+							if ($src[$ptr]=='/') {
+								if ($src[$ptr+1]=='*') {
+									// Multiline comment
+									$str=strstr(
+										substr($src,$ptr+2),'*/',TRUE);
+									$ptr+=strlen($str)+4;
+								}
+								elseif ($src[$ptr+1]=='/') {
+									// Single-line comment
+									$str=strstr(
+										substr($src,$ptr+2),"\n",TRUE);
+									$ptr+=(empty($str))?
+										strlen(substr($src,$ptr)):strlen($str)+2;
+								}
+								else {
+									// Presume it's a regex pattern
+									$regex=TRUE;
+									// Backtrack and validate
+									for ($ofs=$ptr;$ofs;$ofs--) {
+										// Pattern should be preceded by
+										// open parenthesis, colon,
+										// object property or operator
+										if (preg_match(
+											'/(return|[(:=!+\-*&|])$/',
+											substr($src,0,$ofs))) {
+											$data.='/';
+											$ptr++;
+											while ($ptr<$len) {
+												$data.=$src[$ptr];
+												$ptr++;
+												if ($src[$ptr-1]=='\\') {
+													$data.=$src[$ptr];
+													$ptr++;
+												}
+												elseif ($src[$ptr-1]=='/')
+													break;
+											}
+											break;
+										}
+										elseif (!ctype_space($src[$ofs-1])) {
+											// Not a regex pattern
+											$regex=FALSE;
+											break;
+										}
+									}
+									if (!$regex) {
+										// Division operator
+										$data.=$src[$ptr];
+										$ptr++;
+									}
+								}
+								continue;
+							}
+							if (in_array($src[$ptr],['\'','"'])) {
+								$match=$src[$ptr];
+								$data.=$match;
+								$ptr++;
+								// String literal
+								while ($ptr<$len) {
+									$data.=$src[$ptr];
+									$ptr++;
+									if ($src[$ptr-1]=='\\') {
+										$data.=$src[$ptr];
+										$ptr++;
+									}
+									elseif ($src[$ptr-1]==$match)
+										break;
+								}
+								continue;
+							}
+							if (ctype_space($src[$ptr])) {
+								if ($ptr+1<strlen($src) &&
+									preg_match('/[\w'.($ext[0]=='css'?
+										'#\.%+\-*()\[\]':'\$').']{2}|'.
+										'[+\-]{2}/',
+										substr($data,-1).$src[$ptr+1]) ||
+									($ext[0]=='css' && $ptr+2<strlen($src) &&
+									preg_match('/:\w/',substr($src,$ptr+1,2))))
+									$data.=' ';
+								$ptr++;
+								continue;
+							}
+							$data.=$src[$ptr];
+							$ptr++;
+						}
+						if ($ext[0]=='css')
+							$data=str_replace(';}','}',$data);
+						if ($fw->CACHE)
+							$cache->set($hash,$data);
+						$dst.=$data;
+					}
+				}
+		if (PHP_SAPI!='cli' && $header)
+			header('Content-Type: '.$mime.'; charset='.$fw->ENCODING);
+		return $dst;
+	}
+
+	/**
+	*	Retrieve RSS feed and return as an array
+	*	@return array|FALSE
+	*	@param $url string
+	*	@param $max int
+	*	@param $tags string
+	**/
+	function rss($url,$max=10,$tags=NULL) {
+		if (!$data=$this->request($url))
+			return FALSE;
+		// Suppress errors caused by invalid XML structures
+		libxml_use_internal_errors(TRUE);
+		$xml=simplexml_load_string($data['body'],
+			NULL,LIBXML_NOBLANKS|LIBXML_NOERROR);
+		if (!is_object($xml))
+			return FALSE;
+		$out=[];
+		if (isset($xml->channel)) {
+			$out['source']=(string)$xml->channel->title;
+			$max=min($max,count($xml->channel->item));
+			for ($i=0;$i<$max;$i++) {
+				$item=$xml->channel->item[$i];
+				$list=[''=>NULL]+$item->getnamespaces(TRUE);
+				$fields=[];
+				foreach ($list as $ns=>$uri)
+					foreach ($item->children($uri) as $key=>$val)
+						$fields[$ns.($ns?':':'').$key]=(string)$val;
+				$out['feed'][]=$fields;
+			}
+		}
+		else
+			return FALSE;
+		Base::instance()->scrub($out,$tags);
+		return $out;
+	}
+
+	/**
+	*	Retrieve information from whois server
+	*	@return string|FALSE
+	*	@param $addr string
+	*	@param $server string
+	**/
+	function whois($addr,$server='whois.internic.net') {
+		$socket=@fsockopen($server,43,$errno,$errstr);
+		if (!$socket)
+			// Can't establish connection
+			return FALSE;
+		// Set connection timeout parameters
+		stream_set_blocking($socket,FALSE);
+		stream_set_timeout($socket,ini_get('default_socket_timeout'));
+		// Send request
+		fputs($socket,$addr."\r\n");
+		$info=stream_get_meta_data($socket);
+		// Get response
+		$response='';
+		while (!feof($socket) && !$info['timed_out']) {
+			$response.=fgets($socket,4096); // MDFK97
+			$info=stream_get_meta_data($socket);
+		}
+		fclose($socket);
+		return $info['timed_out']?FALSE:trim($response);
+	}
+
+	/**
+	*	Return preset diacritics translation table
+	*	@return array
+	**/
+	function diacritics() {
+		return [
+			'Ǎ'=>'A','А'=>'A','Ā'=>'A','Ă'=>'A','Ą'=>'A','Å'=>'A',
+			'Ǻ'=>'A','Ä'=>'Ae','Á'=>'A','À'=>'A','Ã'=>'A','Â'=>'A',
+			'Æ'=>'AE','Ǽ'=>'AE','Б'=>'B','Ç'=>'C','Ć'=>'C','Ĉ'=>'C',
+			'Č'=>'C','Ċ'=>'C','Ц'=>'C','Ч'=>'Ch','Ð'=>'Dj','Đ'=>'Dj',
+			'Ď'=>'Dj','Д'=>'Dj','É'=>'E','Ę'=>'E','Ё'=>'E','Ė'=>'E',
+			'Ê'=>'E','Ě'=>'E','Ē'=>'E','È'=>'E','Е'=>'E','Э'=>'E',
+			'Ë'=>'E','Ĕ'=>'E','Ф'=>'F','Г'=>'G','Ģ'=>'G','Ġ'=>'G',
+			'Ĝ'=>'G','Ğ'=>'G','Х'=>'H','Ĥ'=>'H','Ħ'=>'H','Ï'=>'I',
+			'Ĭ'=>'I','İ'=>'I','Į'=>'I','Ī'=>'I','Í'=>'I','Ì'=>'I',
+			'И'=>'I','Ǐ'=>'I','Ĩ'=>'I','Î'=>'I','IJ'=>'IJ','Ĵ'=>'J',
+			'Й'=>'J','Я'=>'Ja','Ю'=>'Ju','К'=>'K','Ķ'=>'K','Ĺ'=>'L',
+			'Л'=>'L','Ł'=>'L','Ŀ'=>'L','Ļ'=>'L','Ľ'=>'L','М'=>'M',
+			'Н'=>'N','Ń'=>'N','Ñ'=>'N','Ņ'=>'N','Ň'=>'N','Ō'=>'O',
+			'О'=>'O','Ǿ'=>'O','Ǒ'=>'O','Ơ'=>'O','Ŏ'=>'O','Ő'=>'O',
+			'Ø'=>'O','Ö'=>'Oe','Õ'=>'O','Ó'=>'O','Ò'=>'O','Ô'=>'O',
+			'Œ'=>'OE','П'=>'P','Ŗ'=>'R','Р'=>'R','Ř'=>'R','Ŕ'=>'R',
+			'Ŝ'=>'S','Ş'=>'S','Š'=>'S','Ș'=>'S','Ś'=>'S','С'=>'S',
+			'Ш'=>'Sh','Щ'=>'Shch','Ť'=>'T','Ŧ'=>'T','Ţ'=>'T','Ț'=>'T',
+			'Т'=>'T','Ů'=>'U','Ű'=>'U','Ŭ'=>'U','Ũ'=>'U','Ų'=>'U',
+			'Ū'=>'U','Ǜ'=>'U','Ǚ'=>'U','Ù'=>'U','Ú'=>'U','Ü'=>'Ue',
+			'Ǘ'=>'U','Ǖ'=>'U','У'=>'U','Ư'=>'U','Ǔ'=>'U','Û'=>'U',
+			'В'=>'V','Ŵ'=>'W','Ы'=>'Y','Ŷ'=>'Y','Ý'=>'Y','Ÿ'=>'Y',
+			'Ź'=>'Z','З'=>'Z','Ż'=>'Z','Ž'=>'Z','Ж'=>'Zh','á'=>'a',
+			'ă'=>'a','â'=>'a','à'=>'a','ā'=>'a','ǻ'=>'a','å'=>'a',
+			'ä'=>'ae','ą'=>'a','ǎ'=>'a','ã'=>'a','а'=>'a','ª'=>'a',
+			'æ'=>'ae','ǽ'=>'ae','б'=>'b','č'=>'c','ç'=>'c','ц'=>'c',
+			'ċ'=>'c','ĉ'=>'c','ć'=>'c','ч'=>'ch','ð'=>'dj','ď'=>'dj',
+			'д'=>'dj','đ'=>'dj','э'=>'e','é'=>'e','ё'=>'e','ë'=>'e',
+			'ê'=>'e','е'=>'e','ĕ'=>'e','è'=>'e','ę'=>'e','ě'=>'e',
+			'ė'=>'e','ē'=>'e','ƒ'=>'f','ф'=>'f','ġ'=>'g','ĝ'=>'g',
+			'ğ'=>'g','г'=>'g','ģ'=>'g','х'=>'h','ĥ'=>'h','ħ'=>'h',
+			'ǐ'=>'i','ĭ'=>'i','и'=>'i','ī'=>'i','ĩ'=>'i','į'=>'i',
+			'ı'=>'i','ì'=>'i','î'=>'i','í'=>'i','ï'=>'i','ij'=>'ij',
+			'ĵ'=>'j','й'=>'j','я'=>'ja','ю'=>'ju','ķ'=>'k','к'=>'k',
+			'ľ'=>'l','ł'=>'l','ŀ'=>'l','ĺ'=>'l','ļ'=>'l','л'=>'l',
+			'м'=>'m','ņ'=>'n','ñ'=>'n','ń'=>'n','н'=>'n','ň'=>'n',
+			'ʼn'=>'n','ó'=>'o','ò'=>'o','ǒ'=>'o','ő'=>'o','о'=>'o',
+			'ō'=>'o','º'=>'o','ơ'=>'o','ŏ'=>'o','ô'=>'o','ö'=>'oe',
+			'õ'=>'o','ø'=>'o','ǿ'=>'o','œ'=>'oe','п'=>'p','р'=>'r',
+			'ř'=>'r','ŕ'=>'r','ŗ'=>'r','ſ'=>'s','ŝ'=>'s','ș'=>'s',
+			'š'=>'s','ś'=>'s','с'=>'s','ş'=>'s','ш'=>'sh','щ'=>'shch',
+			'ß'=>'ss','ţ'=>'t','т'=>'t','ŧ'=>'t','ť'=>'t','ț'=>'t',
+			'у'=>'u','ǘ'=>'u','ŭ'=>'u','û'=>'u','ú'=>'u','ų'=>'u',
+			'ù'=>'u','ű'=>'u','ů'=>'u','ư'=>'u','ū'=>'u','ǚ'=>'u',
+			'ǜ'=>'u','ǔ'=>'u','ǖ'=>'u','ũ'=>'u','ü'=>'ue','в'=>'v',
+			'ŵ'=>'w','ы'=>'y','ÿ'=>'y','ý'=>'y','ŷ'=>'y','ź'=>'z',
+			'ž'=>'z','з'=>'z','ż'=>'z','ж'=>'zh','ь'=>'','ъ'=>'',
+			'\''=>'',
+		];
+	}
+
+	/**
+	*	Return a URL/filesystem-friendly version of string
+	*	@return string
+	*	@param $text string
+	**/
+	function slug($text) {
+		return trim(strtolower(preg_replace('/([^\pL\pN])+/u','-',
+			trim(strtr($text,Base::instance()->DIACRITICS+$this->diacritics())))),'-');
+	}
+
+	/**
+	*	Return chunk of text from standard Lorem Ipsum passage
+	*	@return string
+	*	@param $count int
+	*	@param $max int
+	*	@param $std bool
+	**/
+	function filler($count=1,$max=20,$std=TRUE) {
+		$out='';
+		if ($std)
+			$out='Lorem ipsum dolor sit amet, consectetur adipisicing elit, '.
+				'sed do eiusmod tempor incididunt ut labore et dolore magna '.
+				'aliqua.';
+		$rnd=explode(' ',
+			'a ab ad accusamus adipisci alias aliquam amet animi aperiam '.
+			'architecto asperiores aspernatur assumenda at atque aut beatae '.
+			'blanditiis cillum commodi consequatur corporis corrupti culpa '.
+			'cum cupiditate debitis delectus deleniti deserunt dicta '.
+			'dignissimos distinctio dolor ducimus duis ea eaque earum eius '.
+			'eligendi enim eos error esse est eum eveniet ex excepteur '.
+			'exercitationem expedita explicabo facere facilis fugiat harum '.
+			'hic id illum impedit in incidunt ipsa iste itaque iure iusto '.
+			'laborum laudantium libero magnam maiores maxime minim minus '.
+			'modi molestiae mollitia nam natus necessitatibus nemo neque '.
+			'nesciunt nihil nisi nobis non nostrum nulla numquam occaecati '.
+			'odio officia omnis optio pariatur perferendis perspiciatis '.
+			'placeat porro possimus praesentium proident quae quia quibus '.
+			'quo ratione recusandae reiciendis rem repellat reprehenderit '.
+			'repudiandae rerum saepe sapiente sequi similique sint soluta '.
+			'suscipit tempora tenetur totam ut ullam unde vel veniam vero '.
+			'vitae voluptas');
+		for ($i=0,$add=$count-(int)$std;$i<$add;$i++) {
+			shuffle($rnd);
+			$words=array_slice($rnd,0,mt_rand(3,$max));
+			$out.=' '.ucfirst(implode(' ',$words)).'.';
+		}
+		return $out;
+	}
+
+}
+
+if (!function_exists('gzdecode')) {
+
+	/**
+	*	Decode gzip-compressed string
+	*	@param $str string
+	**/
+	function gzdecode($str) {
+		$fw=Base::instance();
+		if (!is_dir($tmp=$fw->TEMP))
+			mkdir($tmp,Base::MODE,TRUE);
+		file_put_contents($file=$tmp.'/'.$fw->SEED.'.'.
+			$fw->hash(uniqid(NULL,TRUE)).'.gz',$str,LOCK_EX);
+		ob_start();
+		readgzfile($file);
+		$out=ob_get_clean();
+		@unlink($file);
+		return $out;
+	}
+
+}

+ 108 - 0
lib/f3/web/geo.php

@@ -0,0 +1,108 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace Web;
+
+//! Geo plug-in
+class Geo extends \Prefab {
+
+	/**
+	*	Return information about specified Unix time zone
+	*	@return array
+	*	@param $zone string
+	**/
+	function tzinfo($zone) {
+		$ref=new \DateTimeZone($zone);
+		$loc=$ref->getLocation();
+		$trn=$ref->getTransitions($now=time(),$now);
+		$out=[
+			'offset'=>$ref->
+				getOffset(new \DateTime('now',new \DateTimeZone('UTC')))/3600,
+			'country'=>$loc['country_code'],
+			'latitude'=>$loc['latitude'],
+			'longitude'=>$loc['longitude'],
+			'dst'=>$trn[0]['isdst']
+		];
+		unset($ref);
+		return $out;
+	}
+
+	/**
+	*	Return geolocation data based on specified/auto-detected IP address
+	*	@return array|FALSE
+	*	@param $ip string
+	**/
+	function location($ip=NULL) {
+		$fw=\Base::instance();
+		$web=\Web::instance();
+		if (!$ip)
+			$ip=$fw->IP;
+		$public=filter_var($ip,FILTER_VALIDATE_IP,
+			FILTER_FLAG_IPV4|FILTER_FLAG_IPV6|
+			FILTER_FLAG_NO_RES_RANGE|FILTER_FLAG_NO_PRIV_RANGE);
+		if (function_exists('geoip_db_avail') &&
+			geoip_db_avail(GEOIP_CITY_EDITION_REV1) &&
+			$out=@geoip_record_by_name($ip)) {
+			$out['request']=$ip;
+			$out['region_code']=$out['region'];
+			$out['region_name']=(!empty($out['country_code']) && !empty($out['region']))
+				? geoip_region_name_by_code($out['country_code'],$out['region']) : '';
+			unset($out['country_code3'],$out['region'],$out['postal_code']);
+			return $out;
+		}
+		if (($req=$web->request('http://www.geoplugin.net/json.gp'.
+			($public?('?ip='.$ip):''))) &&
+			$data=json_decode($req['body'],TRUE)) {
+			$out=[];
+			foreach ($data as $key=>$val)
+				if (!strpos($key,'currency') && $key!=='geoplugin_status'
+					&& $key!=='geoplugin_region')
+					$out[$fw->snakecase(substr($key, 10))]=$val;
+			return $out;
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Return weather data based on specified latitude/longitude
+	*	@return array|FALSE
+	*	@param $latitude float
+	*	@param $longitude float
+	*	@param $key string
+	**/
+	function weather($latitude,$longitude,$key) {
+		$fw=\Base::instance();
+		$web=\Web::instance();
+		$query=[
+			'lat'=>$latitude,
+			'lon'=>$longitude,
+			'APPID'=>$key,
+			'units'=>'metric'
+		];
+		return ($req=$web->request(
+			'http://api.openweathermap.org/data/2.5/weather?'.
+				http_build_query($query)))?
+			json_decode($req['body'],TRUE):
+			FALSE;
+	}
+
+}

+ 58 - 0
lib/f3/web/google/recaptcha.php

@@ -0,0 +1,58 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace Web\Google;
+
+//! Google ReCAPTCHA v2 plug-in
+class Recaptcha {
+
+	const
+		//! API URL
+		URL_Recaptcha='https://www.google.com/recaptcha/api/siteverify';
+
+	/**
+	 *	Verify reCAPTCHA response
+	 *	@param string $secret
+	 *	@param string $response
+	 *	@return bool
+	 **/
+	static function verify($secret,$response=NULL) {
+		$fw=\Base::instance();
+		if (!isset($response))
+			$response=$fw->{'POST.g-recaptcha-response'};
+		if (!$response)
+			return FALSE;
+		$web=\Web::instance();
+		$out=$web->request(self::URL_Recaptcha,[
+			'method'=>'POST',
+			'content'=>http_build_query([
+				'secret'=>$secret,
+				'response'=>$response,
+				'remoteip'=>$fw->IP
+			]),
+		]);
+		return isset($out['body']) &&
+			($json=json_decode($out['body'],TRUE)) &&
+			isset($json['success']) && $json['success'];
+	}
+
+}

+ 65 - 0
lib/f3/web/google/staticmap.php

@@ -0,0 +1,65 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace Web\Google;
+
+//! Google Static Maps API v2 plug-in
+class StaticMap {
+
+	const
+		//! API URL
+		URL_Static='http://maps.googleapis.com/maps/api/staticmap';
+
+	protected
+		//! Query arguments
+		$query=array();
+
+	/**
+	*	Specify API key-value pair via magic call
+	*	@return object
+	*	@param $func string
+	*	@param $args array
+	**/
+	function __call($func,array $args) {
+		$this->query[]=array($func,$args[0]);
+		return $this;
+	}
+
+	/**
+	*	Generate map
+	*	@return string
+	**/
+	function dump() {
+		$fw=\Base::instance();
+		$web=\Web::instance();
+		$out='';
+		return ($req=$web->request(
+			self::URL_Static.'?'.array_reduce(
+				$this->query,
+				function($out,$item) {
+					return ($out.=($out?'&':'').
+						urlencode($item[0]).'='.urlencode($item[1]));
+				}
+			))) && $req['body']?$req['body']:FALSE;
+	}
+
+}

+ 136 - 0
lib/f3/web/oauth2.php

@@ -0,0 +1,136 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace Web;
+
+//! Lightweight OAuth2 client
+class OAuth2 extends \Magic {
+
+	protected
+		//! Scopes and claims
+		$args=[];
+
+	/**
+	*	Return OAuth2 authentication URI
+	*	@return string
+	*	@param $endpoint string
+	**/
+	function uri($endpoint) {
+		return $endpoint.'?'.http_build_query($this->args);
+	}
+
+	/**
+	*	Send request to API/token endpoint
+	*	@return string|FALSE
+	*	@param $uri string
+	*	@param $method string
+	*	@param $token array
+	**/
+	function request($uri,$method,$token=NULL) {
+		$web=\Web::instance();
+		$options=[
+			'method'=>$method,
+			'content'=>http_build_query($this->args),
+			'header'=>['Accept: application/json']
+		];
+		if ($token)
+			array_push($options['header'],'Authorization: Bearer '.$token);
+		elseif ($method=='POST')
+			array_push($options['header'],'Authorization: Basic '.
+				base64_encode(
+					$this->args['client_id'].':'.
+					$this->args['client_secret']
+				)
+			);
+		$response=$web->request($uri,$options);
+		if ($response['error'])
+			user_error($response['error'],E_USER_ERROR);
+		return $response['body'] &&
+			preg_grep('/HTTP\/1\.\d 200/',$response['headers'])?
+			json_decode($response['body'],TRUE):
+			FALSE;
+	}
+
+	/**
+	*	Parse JSON Web token
+	*	@return array
+	*	@param $token string
+	**/
+	function jwt($token) {
+		return json_decode(
+			base64_decode(
+				str_replace(
+					['-','_'],
+					['+','/'],
+					explode('.',$token)[1]
+				)
+			),
+			TRUE
+		);
+	}
+
+	/**
+	*	Return TRUE if scope/claim exists
+	*	@return bool
+	*	@param $key string
+	**/
+	function exists($key) {
+		return isset($this->args[$key]);
+	}
+
+	/**
+	*	Bind value to scope/claim
+	*	@return string
+	*	@param $key string
+	*	@param $val string
+	**/
+	function set($key,$val) {
+		return $this->args[$key]=$val;
+	}
+
+	/**
+	*	Return value of scope/claim
+	*	@return mixed
+	*	@param $key string
+	**/
+	function &get($key) {
+		if (isset($this->args[$key]))
+			$val=&$this->args[$key];
+		else
+			$val=NULL;
+		return $val;
+	}
+
+	/**
+	*	Remove scope/claim
+	*	@return NULL
+	*	@param $key string
+	**/
+	function clear($key=NULL) {
+		if ($key)
+			unset($this->args[$key]);
+		else
+			$this->args=[];
+	}
+
+}
+

+ 248 - 0
lib/f3/web/openid.php

@@ -0,0 +1,248 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace Web;
+
+//! OpenID consumer
+class OpenID extends \Magic {
+
+	protected
+		//! OpenID provider endpoint URL
+		$url,
+		//! HTTP request parameters
+		$args=[];
+
+	/**
+	*	Determine OpenID provider
+	*	@return string|FALSE
+	*	@param $proxy string
+	**/
+	protected function discover($proxy) {
+		// Normalize
+		if (!preg_match('/https?:\/\//i',$this->args['endpoint']))
+			$this->args['endpoint']='http://'.$this->args['endpoint'];
+		$url=parse_url($this->args['endpoint']);
+		// Remove fragment; reconnect parts
+		$this->args['endpoint']=$url['scheme'].'://'.
+			(isset($url['user'])?
+				($url['user'].
+				(isset($url['pass'])?(':'.$url['pass']):'').'@'):'').
+			strtolower($url['host']).(isset($url['path'])?$url['path']:'/').
+			(isset($url['query'])?('?'.$url['query']):'');
+		// HTML-based discovery of OpenID provider
+		$req=\Web::instance()->
+			request($this->args['endpoint'],['proxy'=>$proxy]);
+		if (!$req)
+			return FALSE;
+		$type=array_values(preg_grep('/Content-Type:/',$req['headers']));
+		if ($type &&
+			preg_match('/application\/xrds\+xml|text\/xml/',$type[0]) &&
+			($sxml=simplexml_load_string($req['body'])) &&
+			($xrds=json_decode(json_encode($sxml),TRUE)) &&
+			isset($xrds['XRD'])) {
+			// XRDS document
+			$svc=$xrds['XRD']['Service'];
+			if (isset($svc[0]))
+				$svc=$svc[0];
+			$svc_type=is_array($svc['Type'])?$svc['Type']:array($svc['Type']);
+			if (preg_grep('/http:\/\/specs\.openid\.net\/auth\/2.0\/'.
+					'(?:server|signon)/',$svc_type)) {
+				$this->args['provider']=$svc['URI'];
+				if (isset($svc['LocalID']))
+					$this->args['localidentity']=$svc['LocalID'];
+				elseif (isset($svc['CanonicalID']))
+					$this->args['localidentity']=$svc['CanonicalID'];
+			}
+			$this->args['server']=$svc['URI'];
+			if (isset($svc['Delegate']))
+				$this->args['delegate']=$svc['Delegate'];
+		}
+		else {
+			$len=strlen($req['body']);
+			$ptr=0;
+			// Parse document
+			while ($ptr<$len)
+				if (preg_match(
+					'/^<link\b((?:\h+\w+\h*=\h*'.
+					'(?:"(?:.+?)"|\'(?:.+?)\'))*)\h*\/?>/is',
+					substr($req['body'],$ptr),$parts)) {
+					if ($parts[1] &&
+						// Process attributes
+						preg_match_all('/\b(rel|href)\h*=\h*'.
+							'(?:"(.+?)"|\'(.+?)\')/s',$parts[1],$attr,
+							PREG_SET_ORDER)) {
+						$node=[];
+						foreach ($attr as $kv)
+							$node[$kv[1]]=isset($kv[2])?$kv[2]:$kv[3];
+						if (isset($node['rel']) &&
+							preg_match('/openid2?\.(\w+)/',
+								$node['rel'],$var) &&
+							isset($node['href']))
+							$this->args[$var[1]]=$node['href'];
+
+					}
+					$ptr+=strlen($parts[0]);
+				}
+				else
+					$ptr++;
+		}
+		// Get OpenID provider's endpoint URL
+		if (isset($this->args['provider'])) {
+			// OpenID 2.0
+			$this->args['ns']='http://specs.openid.net/auth/2.0';
+			if (isset($this->args['localidentity']))
+				$this->args['identity']=$this->args['localidentity'];
+			if (isset($this->args['trust_root']))
+				$this->args['realm']=$this->args['trust_root'];
+		}
+		elseif (isset($this->args['server'])) {
+			// OpenID 1.1
+			$this->args['ns']='http://openid.net/signon/1.1';
+			if (isset($this->args['delegate']))
+				$this->args['identity']=$this->args['delegate'];
+		}
+		if (isset($this->args['provider'])) {
+			// OpenID 2.0
+			if (empty($this->args['claimed_id']))
+				$this->args['claimed_id']=$this->args['identity'];
+			return $this->args['provider'];
+		}
+		elseif (isset($this->args['server']))
+			// OpenID 1.1
+			return $this->args['server'];
+		else
+			return FALSE;
+	}
+
+	/**
+	*	Initiate OpenID authentication sequence; Return FALSE on failure
+	*	or redirect to OpenID provider URL
+	*	@return bool
+	*	@param $proxy string
+	*	@param $attr array
+	*	@param $reqd string|array
+	**/
+	function auth($proxy=NULL,$attr=[],array $reqd=NULL) {
+		$fw=\Base::instance();
+		$root=$fw->SCHEME.'://'.$fw->HOST;
+		if (empty($this->args['trust_root']))
+			$this->args['trust_root']=$root.$fw->BASE.'/';
+		if (empty($this->args['return_to']))
+			$this->args['return_to']=$root.$_SERVER['REQUEST_URI'];
+		$this->args['mode']='checkid_setup';
+		if ($this->url=$this->discover($proxy)) {
+			if ($attr) {
+				$this->args['ns.ax']='http://openid.net/srv/ax/1.0';
+				$this->args['ax.mode']='fetch_request';
+				foreach ($attr as $key=>$val)
+					$this->args['ax.type.'.$key]=$val;
+				$this->args['ax.required']=is_string($reqd)?
+					$reqd:implode(',',$reqd);
+			}
+			$var=[];
+			foreach ($this->args as $key=>$val)
+				$var['openid.'.$key]=$val;
+			$fw->reroute($this->url.'?'.http_build_query($var));
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Return TRUE if OpenID verification was successful
+	*	@return bool
+	*	@param $proxy string
+	**/
+	function verified($proxy=NULL) {
+		preg_match_all('/(?<=^|&)openid\.([^=]+)=([^&]+)/',
+			$_SERVER['QUERY_STRING'],$matches,PREG_SET_ORDER);
+		foreach ($matches as $match)
+			$this->args[$match[1]]=urldecode($match[2]);
+		if (isset($this->args['mode']) &&
+			$this->args['mode']!='error' &&
+			$this->url=$this->discover($proxy)) {
+			$this->args['mode']='check_authentication';
+			$var=[];
+			foreach ($this->args as $key=>$val)
+				$var['openid.'.$key]=$val;
+			$req=\Web::instance()->request(
+				$this->url,
+				[
+					'method'=>'POST',
+					'content'=>http_build_query($var),
+					'proxy'=>$proxy
+				]
+			);
+			return (bool)preg_match('/is_valid:true/i',$req['body']);
+		}
+		return FALSE;
+	}
+
+	/**
+	*	Return OpenID response fields
+	*	@return array
+	**/
+	function response() {
+		return $this->args;
+	}
+
+	/**
+	*	Return TRUE if OpenID request parameter exists
+	*	@return bool
+	*	@param $key string
+	**/
+	function exists($key) {
+		return isset($this->args[$key]);
+	}
+
+	/**
+	*	Bind value to OpenID request parameter
+	*	@return string
+	*	@param $key string
+	*	@param $val string
+	**/
+	function set($key,$val) {
+		return $this->args[$key]=$val;
+	}
+
+	/**
+	*	Return value of OpenID request parameter
+	*	@return mixed
+	*	@param $key string
+	**/
+	function &get($key) {
+		if (isset($this->args[$key]))
+			$val=&$this->args[$key];
+		else
+			$val=NULL;
+		return $val;
+	}
+
+	/**
+	*	Remove OpenID request parameter
+	*	@return NULL
+	*	@param $key
+	**/
+	function clear($key) {
+		unset($this->args[$key]);
+	}
+
+}

+ 176 - 0
lib/f3/web/pingback.php

@@ -0,0 +1,176 @@
+<?php
+
+/*
+
+	Copyright (c) 2009-2017 F3::Factory/Bong Cosca, All rights reserved.
+
+	This file is part of the Fat-Free Framework (http://fatfreeframework.com).
+
+	This is free software: you can redistribute it and/or modify it under the
+	terms of the GNU General Public License as published by the Free Software
+	Foundation, either version 3 of the License, or later.
+
+	Fat-Free Framework is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+	General Public License for more details.
+
+	You should have received a copy of the GNU General Public License along
+	with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.
+
+*/
+
+namespace Web;
+
+//! Pingback 1.0 protocol (client and server) implementation
+class Pingback extends \Prefab {
+
+	protected
+		//! Transaction history
+		$log;
+
+	/**
+	*	Return TRUE if URL points to a pingback-enabled resource
+	*	@return bool
+	*	@param $url
+	**/
+	protected function enabled($url) {
+		$web=\Web::instance();
+		$req=$web->request($url);
+		$found=FALSE;
+		if ($req['body']) {
+			// Look for pingback header
+			foreach ($req['headers'] as $header)
+				if (preg_match('/^X-Pingback:\h*(.+)/',$header,$href)) {
+					$found=$href[1];
+					break;
+				}
+			if (!$found &&
+				// Scan page for pingback link tag
+				preg_match('/<link\h+(.+?)\h*\/?>/i',$req['body'],$parts) &&
+				preg_match('/rel\h*=\h*"pingback"/i',$parts[1]) &&
+				preg_match('/href\h*=\h*"\h*(.+?)\h*"/i',$parts[1],$href))
+				$found=$href[1];
+		}
+		return $found;
+	}
+
+	/**
+	*	Load local page contents, parse HTML anchor tags, find permalinks,
+	*	and send XML-RPC calls to corresponding pingback servers
+	*	@return NULL
+	*	@param $source string
+	**/
+	function inspect($source) {
+		$fw=\Base::instance();
+		$web=\Web::instance();
+		$parts=parse_url($source);
+		if (empty($parts['scheme']) || empty($parts['host']) ||
+			$parts['host']==$fw->HOST) {
+			$req=$web->request($source);
+			$doc=new \DOMDocument('1.0',$fw->ENCODING);
+			$doc->stricterrorchecking=FALSE;
+			$doc->recover=TRUE;
+			if (@$doc->loadhtml($req['body'])) {
+				// Parse anchor tags
+				$links=$doc->getelementsbytagname('a');
+				foreach ($links as $link) {
+					$permalink=$link->getattribute('href');
+					// Find pingback-enabled resources
+					if ($permalink && $found=$this->enabled($permalink)) {
+						$req=$web->request($found,
+							[
+								'method'=>'POST',
+								'header'=>'Content-Type: application/xml',
+								'content'=>xmlrpc_encode_request(
+									'pingback.ping',
+									[$source,$permalink],
+									['encoding'=>$fw->ENCODING]
+								)
+							]
+						);
+						if ($req['body'])
+							$this->log.=date('r').' '.
+								$permalink.' [permalink:'.$found.']'.PHP_EOL.
+								$req['body'].PHP_EOL;
+					}
+				}
+			}
+			unset($doc);
+		}
+	}
+
+	/**
+	*	Receive ping, check if local page is pingback-enabled, verify
+	*	source contents, and return XML-RPC response
+	*	@return string
+	*	@param $func callback
+	*	@param $path string
+	**/
+	function listen($func,$path=NULL) {
+		$fw=\Base::instance();
+		if (PHP_SAPI!='cli') {
+			header('X-Powered-By: '.$fw->PACKAGE);
+			header('Content-Type: application/xml; '.
+				'charset='.$charset=$fw->ENCODING);
+		}
+		if (!$path)
+			$path=$fw->BASE;
+		$web=\Web::instance();
+		$args=xmlrpc_decode_request($fw->BODY,$method,$charset);
+		$options=['encoding'=>$charset];
+		if ($method=='pingback.ping' && isset($args[0],$args[1])) {
+			list($source,$permalink)=$args;
+			$doc=new \DOMDocument('1.0',$fw->ENCODING);
+			// Check local page if pingback-enabled
+			$parts=parse_url($permalink);
+			if ((empty($parts['scheme']) ||
+				$parts['host']==$fw->HOST) &&
+				preg_match('/^'.preg_quote($path,'/').'/'.
+					($fw->CASELESS?'i':''),$parts['path']) &&
+				$this->enabled($permalink)) {
+				// Check source
+				$parts=parse_url($source);
+				if ((empty($parts['scheme']) ||
+					$parts['host']==$fw->HOST) &&
+					($req=$web->request($source)) &&
+					$doc->loadhtml($req['body'])) {
+					$links=$doc->getelementsbytagname('a');
+					foreach ($links as $link) {
+						if ($link->getattribute('href')==$permalink) {
+							call_user_func_array($func,[$source,$req['body']]);
+							// Success
+							die(xmlrpc_encode_request(NULL,$source,$options));
+						}
+					}
+					// No link to local page
+					die(xmlrpc_encode_request(NULL,0x11,$options));
+				}
+				// Source failure
+				die(xmlrpc_encode_request(NULL,0x10,$options));
+			}
+			// Doesn't exist (or not pingback-enabled)
+			die(xmlrpc_encode_request(NULL,0x21,$options));
+		}
+		// Access denied
+		die(xmlrpc_encode_request(NULL,0x31,$options));
+	}
+
+	/**
+	*	Return transaction history
+	*	@return string
+	**/
+	function log() {
+		return $this->log;
+	}
+
+	/**
+	*	Instantiate class
+	*	@return object
+	**/
+	function __construct() {
+		// Suppress errors caused by invalid HTML structures
+		libxml_use_internal_errors(TRUE);
+	}
+
+}

+ 166 - 161
package-lock.json

@@ -29,9 +29,9 @@
       "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz",
       "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=",
       "requires": {
-        "chalk": "1.1.3",
-        "esutils": "2.0.2",
-        "js-tokens": "3.0.2"
+        "chalk": "^1.1.3",
+        "esutils": "^2.0.2",
+        "js-tokens": "^3.0.2"
       }
     },
     "babel-core": {
@@ -39,25 +39,25 @@
       "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.0.tgz",
       "integrity": "sha1-rzL3izGm/O8RnIew/Y2XU/A6C7g=",
       "requires": {
-        "babel-code-frame": "6.26.0",
-        "babel-generator": "6.26.1",
-        "babel-helpers": "6.24.1",
-        "babel-messages": "6.23.0",
-        "babel-register": "6.26.0",
-        "babel-runtime": "6.26.0",
-        "babel-template": "6.26.0",
-        "babel-traverse": "6.26.0",
-        "babel-types": "6.26.0",
-        "babylon": "6.18.0",
-        "convert-source-map": "1.5.1",
-        "debug": "2.6.9",
-        "json5": "0.5.1",
-        "lodash": "4.17.5",
-        "minimatch": "3.0.4",
-        "path-is-absolute": "1.0.1",
-        "private": "0.1.8",
-        "slash": "1.0.0",
-        "source-map": "0.5.7"
+        "babel-code-frame": "^6.26.0",
+        "babel-generator": "^6.26.0",
+        "babel-helpers": "^6.24.1",
+        "babel-messages": "^6.23.0",
+        "babel-register": "^6.26.0",
+        "babel-runtime": "^6.26.0",
+        "babel-template": "^6.26.0",
+        "babel-traverse": "^6.26.0",
+        "babel-types": "^6.26.0",
+        "babylon": "^6.18.0",
+        "convert-source-map": "^1.5.0",
+        "debug": "^2.6.8",
+        "json5": "^0.5.1",
+        "lodash": "^4.17.4",
+        "minimatch": "^3.0.4",
+        "path-is-absolute": "^1.0.1",
+        "private": "^0.1.7",
+        "slash": "^1.0.0",
+        "source-map": "^0.5.6"
       }
     },
     "babel-generator": {
@@ -65,14 +65,14 @@
       "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz",
       "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==",
       "requires": {
-        "babel-messages": "6.23.0",
-        "babel-runtime": "6.26.0",
-        "babel-types": "6.26.0",
-        "detect-indent": "4.0.0",
-        "jsesc": "1.3.0",
-        "lodash": "4.17.5",
-        "source-map": "0.5.7",
-        "trim-right": "1.0.1"
+        "babel-messages": "^6.23.0",
+        "babel-runtime": "^6.26.0",
+        "babel-types": "^6.26.0",
+        "detect-indent": "^4.0.0",
+        "jsesc": "^1.3.0",
+        "lodash": "^4.17.4",
+        "source-map": "^0.5.7",
+        "trim-right": "^1.0.1"
       }
     },
     "babel-helper-evaluate-path": {
@@ -115,8 +115,8 @@
       "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz",
       "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=",
       "requires": {
-        "babel-runtime": "6.26.0",
-        "babel-template": "6.26.0"
+        "babel-runtime": "^6.22.0",
+        "babel-template": "^6.24.1"
       }
     },
     "babel-messages": {
@@ -124,7 +124,7 @@
       "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz",
       "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=",
       "requires": {
-        "babel-runtime": "6.26.0"
+        "babel-runtime": "^6.22.0"
       }
     },
     "babel-plugin-minify-builtins": {
@@ -132,7 +132,7 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-minify-builtins/-/babel-plugin-minify-builtins-0.3.0.tgz",
       "integrity": "sha512-MqhSHlxkmgURqj3144qPksbZ/qof1JWdumcbucc4tysFcf3P3V3z3munTevQgKEFNMd8F5/ECGnwb63xogLjAg==",
       "requires": {
-        "babel-helper-evaluate-path": "0.3.0"
+        "babel-helper-evaluate-path": "^0.3.0"
       }
     },
     "babel-plugin-minify-constant-folding": {
@@ -140,7 +140,7 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-minify-constant-folding/-/babel-plugin-minify-constant-folding-0.3.0.tgz",
       "integrity": "sha512-1XeRpx+aY1BuNY6QU/cm6P+FtEi3ar3XceYbmC+4q4W+2Ewq5pL7V68oHg1hKXkBIE0Z4/FjSoHz6vosZLOe/A==",
       "requires": {
-        "babel-helper-evaluate-path": "0.3.0"
+        "babel-helper-evaluate-path": "^0.3.0"
       }
     },
     "babel-plugin-minify-dead-code-elimination": {
@@ -148,10 +148,10 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.3.0.tgz",
       "integrity": "sha512-SjM2Fzg85YZz+q/PNJ/HU4O3W98FKFOiP9K5z3sfonlamGOzvZw3Eup2OTiEBsbbqTeY8yzNCAv3qpJRYCgGmw==",
       "requires": {
-        "babel-helper-evaluate-path": "0.3.0",
-        "babel-helper-mark-eval-scopes": "0.3.0",
-        "babel-helper-remove-or-void": "0.3.0",
-        "lodash.some": "4.6.0"
+        "babel-helper-evaluate-path": "^0.3.0",
+        "babel-helper-mark-eval-scopes": "^0.3.0",
+        "babel-helper-remove-or-void": "^0.3.0",
+        "lodash.some": "^4.6.0"
       }
     },
     "babel-plugin-minify-flip-comparisons": {
@@ -159,7 +159,7 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-minify-flip-comparisons/-/babel-plugin-minify-flip-comparisons-0.3.0.tgz",
       "integrity": "sha512-B8lK+ekcpSNVH7PZpWDe5nC5zxjRiiT4nTsa6h3QkF3Kk6y9qooIFLemdGlqBq6j0zALEnebvCpw8v7gAdpgnw==",
       "requires": {
-        "babel-helper-is-void-0": "0.3.0"
+        "babel-helper-is-void-0": "^0.3.0"
       }
     },
     "babel-plugin-minify-guarded-expressions": {
@@ -167,7 +167,7 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.3.0.tgz",
       "integrity": "sha512-O+6CvF5/Ttsth3LMg4/BhyvVZ82GImeKMXGdVRQGK/8jFiP15EjRpdgFlxv3cnqRjqdYxLCS6r28VfLpb9C/kA==",
       "requires": {
-        "babel-helper-flip-expressions": "0.3.0"
+        "babel-helper-flip-expressions": "^0.3.0"
       }
     },
     "babel-plugin-minify-infinity": {
@@ -180,7 +180,7 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-minify-mangle-names/-/babel-plugin-minify-mangle-names-0.3.0.tgz",
       "integrity": "sha512-PYTonhFWURsfAN8achDwvR5Xgy6EeTClLz+fSgGRqjAIXb0OyFm3/xfccbQviVi1qDXmlSnt6oJhBg8KE4Fn7Q==",
       "requires": {
-        "babel-helper-mark-eval-scopes": "0.3.0"
+        "babel-helper-mark-eval-scopes": "^0.3.0"
       }
     },
     "babel-plugin-minify-numeric-literals": {
@@ -198,9 +198,9 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.3.0.tgz",
       "integrity": "sha512-2M16ytQOCqBi7bYMu4DCWn8e6KyFCA108F6+tVrBJxOmm5u2sOmTFEa8s94tR9RHRRNYmcUf+rgidfnzL3ik9Q==",
       "requires": {
-        "babel-helper-flip-expressions": "0.3.0",
-        "babel-helper-is-nodes-equiv": "0.0.1",
-        "babel-helper-to-multiple-sequence-expressions": "0.3.0"
+        "babel-helper-flip-expressions": "^0.3.0",
+        "babel-helper-is-nodes-equiv": "^0.0.1",
+        "babel-helper-to-multiple-sequence-expressions": "^0.3.0"
       }
     },
     "babel-plugin-minify-type-constructors": {
@@ -208,7 +208,7 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-minify-type-constructors/-/babel-plugin-minify-type-constructors-0.3.0.tgz",
       "integrity": "sha512-XRXpvsUCPeVw9YEUw+9vSiugcSZfow81oIJT0yR9s8H4W7yJ6FHbImi5DJHoL8KcDUjYnL9wYASXk/fOkbyR6Q==",
       "requires": {
-        "babel-helper-is-void-0": "0.3.0"
+        "babel-helper-is-void-0": "^0.3.0"
       }
     },
     "babel-plugin-transform-inline-consecutive-adds": {
@@ -236,7 +236,7 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-transform-property-literals/-/babel-plugin-transform-property-literals-6.9.1.tgz",
       "integrity": "sha1-aXD5Oxd5OrzenPJdLozRPgCI5ck=",
       "requires": {
-        "esutils": "2.0.2"
+        "esutils": "^2.0.2"
       }
     },
     "babel-plugin-transform-regexp-constructors": {
@@ -259,7 +259,7 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-undefined/-/babel-plugin-transform-remove-undefined-0.3.0.tgz",
       "integrity": "sha512-TYGQucc8iP3LJwN3kDZLEz5aa/2KuFrqpT+s8f8NnHsBU1sAgR3y8Opns0xhC+smyDYWscqFCKM1gbkWQOhhnw==",
       "requires": {
-        "babel-helper-evaluate-path": "0.3.0"
+        "babel-helper-evaluate-path": "^0.3.0"
       }
     },
     "babel-plugin-transform-simplify-comparison-operators": {
@@ -277,29 +277,29 @@
       "resolved": "https://registry.npmjs.org/babel-preset-minify/-/babel-preset-minify-0.3.0.tgz",
       "integrity": "sha512-+VV2GWEyak3eDOmzT1DDMuqHrw3VbE9nBNkx2LLVs4pH/Me32ND8DRpVDd8IRvk1xX5p75nygyRPtkMh6GIAbQ==",
       "requires": {
-        "babel-plugin-minify-builtins": "0.3.0",
-        "babel-plugin-minify-constant-folding": "0.3.0",
-        "babel-plugin-minify-dead-code-elimination": "0.3.0",
-        "babel-plugin-minify-flip-comparisons": "0.3.0",
-        "babel-plugin-minify-guarded-expressions": "0.3.0",
-        "babel-plugin-minify-infinity": "0.3.0",
-        "babel-plugin-minify-mangle-names": "0.3.0",
-        "babel-plugin-minify-numeric-literals": "0.3.0",
-        "babel-plugin-minify-replace": "0.3.0",
-        "babel-plugin-minify-simplify": "0.3.0",
-        "babel-plugin-minify-type-constructors": "0.3.0",
-        "babel-plugin-transform-inline-consecutive-adds": "0.3.0",
-        "babel-plugin-transform-member-expression-literals": "6.9.1",
-        "babel-plugin-transform-merge-sibling-variables": "6.9.1",
-        "babel-plugin-transform-minify-booleans": "6.9.1",
-        "babel-plugin-transform-property-literals": "6.9.1",
-        "babel-plugin-transform-regexp-constructors": "0.3.0",
-        "babel-plugin-transform-remove-console": "6.9.1",
-        "babel-plugin-transform-remove-debugger": "6.9.1",
-        "babel-plugin-transform-remove-undefined": "0.3.0",
-        "babel-plugin-transform-simplify-comparison-operators": "6.9.1",
-        "babel-plugin-transform-undefined-to-void": "6.9.1",
-        "lodash.isplainobject": "4.0.6"
+        "babel-plugin-minify-builtins": "^0.3.0",
+        "babel-plugin-minify-constant-folding": "^0.3.0",
+        "babel-plugin-minify-dead-code-elimination": "^0.3.0",
+        "babel-plugin-minify-flip-comparisons": "^0.3.0",
+        "babel-plugin-minify-guarded-expressions": "^0.3.0",
+        "babel-plugin-minify-infinity": "^0.3.0",
+        "babel-plugin-minify-mangle-names": "^0.3.0",
+        "babel-plugin-minify-numeric-literals": "^0.3.0",
+        "babel-plugin-minify-replace": "^0.3.0",
+        "babel-plugin-minify-simplify": "^0.3.0",
+        "babel-plugin-minify-type-constructors": "^0.3.0",
+        "babel-plugin-transform-inline-consecutive-adds": "^0.3.0",
+        "babel-plugin-transform-member-expression-literals": "^6.9.0",
+        "babel-plugin-transform-merge-sibling-variables": "^6.9.0",
+        "babel-plugin-transform-minify-booleans": "^6.9.0",
+        "babel-plugin-transform-property-literals": "^6.9.0",
+        "babel-plugin-transform-regexp-constructors": "^0.3.0",
+        "babel-plugin-transform-remove-console": "^6.9.0",
+        "babel-plugin-transform-remove-debugger": "^6.9.0",
+        "babel-plugin-transform-remove-undefined": "^0.3.0",
+        "babel-plugin-transform-simplify-comparison-operators": "^6.9.0",
+        "babel-plugin-transform-undefined-to-void": "^6.9.0",
+        "lodash.isplainobject": "^4.0.6"
       }
     },
     "babel-register": {
@@ -307,13 +307,13 @@
       "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz",
       "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=",
       "requires": {
-        "babel-core": "6.26.0",
-        "babel-runtime": "6.26.0",
-        "core-js": "2.5.5",
-        "home-or-tmp": "2.0.0",
-        "lodash": "4.17.5",
-        "mkdirp": "0.5.1",
-        "source-map-support": "0.4.18"
+        "babel-core": "^6.26.0",
+        "babel-runtime": "^6.26.0",
+        "core-js": "^2.5.0",
+        "home-or-tmp": "^2.0.0",
+        "lodash": "^4.17.4",
+        "mkdirp": "^0.5.1",
+        "source-map-support": "^0.4.15"
       }
     },
     "babel-runtime": {
@@ -321,8 +321,8 @@
       "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
       "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
       "requires": {
-        "core-js": "2.5.5",
-        "regenerator-runtime": "0.11.1"
+        "core-js": "^2.4.0",
+        "regenerator-runtime": "^0.11.0"
       }
     },
     "babel-template": {
@@ -330,11 +330,11 @@
       "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz",
       "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=",
       "requires": {
-        "babel-runtime": "6.26.0",
-        "babel-traverse": "6.26.0",
-        "babel-types": "6.26.0",
-        "babylon": "6.18.0",
-        "lodash": "4.17.5"
+        "babel-runtime": "^6.26.0",
+        "babel-traverse": "^6.26.0",
+        "babel-types": "^6.26.0",
+        "babylon": "^6.18.0",
+        "lodash": "^4.17.4"
       }
     },
     "babel-traverse": {
@@ -342,15 +342,15 @@
       "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz",
       "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=",
       "requires": {
-        "babel-code-frame": "6.26.0",
-        "babel-messages": "6.23.0",
-        "babel-runtime": "6.26.0",
-        "babel-types": "6.26.0",
-        "babylon": "6.18.0",
-        "debug": "2.6.9",
-        "globals": "9.18.0",
-        "invariant": "2.2.4",
-        "lodash": "4.17.5"
+        "babel-code-frame": "^6.26.0",
+        "babel-messages": "^6.23.0",
+        "babel-runtime": "^6.26.0",
+        "babel-types": "^6.26.0",
+        "babylon": "^6.18.0",
+        "debug": "^2.6.8",
+        "globals": "^9.18.0",
+        "invariant": "^2.2.2",
+        "lodash": "^4.17.4"
       }
     },
     "babel-types": {
@@ -358,10 +358,10 @@
       "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz",
       "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=",
       "requires": {
-        "babel-runtime": "6.26.0",
-        "esutils": "2.0.2",
-        "lodash": "4.17.5",
-        "to-fast-properties": "1.0.3"
+        "babel-runtime": "^6.26.0",
+        "esutils": "^2.0.2",
+        "lodash": "^4.17.4",
+        "to-fast-properties": "^1.0.3"
       }
     },
     "babylon": {
@@ -375,16 +375,16 @@
       "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
     },
     "bootstrap": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.0.0.tgz",
-      "integrity": "sha512-gulJE5dGFo6Q61V/whS6VM4WIyrlydXfCgkE+Gxe5hjrJ8rXLLZlALq7zq2RPhOc45PSwQpJkrTnc2KgD6cvmA=="
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.1.1.tgz",
+      "integrity": "sha512-SpiDSOcbg4J/PjVSt4ny5eY6j74VbVSjROY4Fb/WIUXBV9cnb5luyR4KnPvNoXuGnBK1T+nJIWqRsvU3yP8Mcg=="
     },
     "brace-expansion": {
       "version": "1.1.11",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
       "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
       "requires": {
-        "balanced-match": "1.0.0",
+        "balanced-match": "^1.0.0",
         "concat-map": "0.0.1"
       }
     },
@@ -393,11 +393,11 @@
       "resolved": "https://registry.npmjs.org/butternut/-/butternut-0.4.6.tgz",
       "integrity": "sha1-HuTkNZZekk99JFueXmphTLGZFbY=",
       "requires": {
-        "acorn": "5.5.3",
-        "chalk": "1.1.3",
-        "magic-string": "0.20.0",
-        "minimist": "1.2.0",
-        "sourcemap-codec": "1.4.1"
+        "acorn": "^5.0.3",
+        "chalk": "^1.1.3",
+        "magic-string": "^0.20.0",
+        "minimist": "^1.2.0",
+        "sourcemap-codec": "^1.3.1"
       },
       "dependencies": {
         "minimist": {
@@ -412,11 +412,11 @@
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
       "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
       "requires": {
-        "ansi-styles": "2.2.1",
-        "escape-string-regexp": "1.0.5",
-        "has-ansi": "2.0.0",
-        "strip-ansi": "3.0.1",
-        "supports-color": "2.0.0"
+        "ansi-styles": "^2.2.1",
+        "escape-string-regexp": "^1.0.2",
+        "has-ansi": "^2.0.0",
+        "strip-ansi": "^3.0.0",
+        "supports-color": "^2.0.0"
       }
     },
     "clean-css": {
@@ -424,7 +424,7 @@
       "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.11.tgz",
       "integrity": "sha1-Ls3xRaujj1R0DybO/Q/z4D4SXWo=",
       "requires": {
-        "source-map": "0.5.7"
+        "source-map": "0.5.x"
       }
     },
     "clone": {
@@ -447,9 +447,9 @@
       "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.2.tgz",
       "integrity": "sha512-Bq6+4t+lbM8vhTs/Bef5c5AdEMtapp/iFb6+s4/Hh9MVTt8OLKH7ZOOZSCT+Ys7hsHvqv0GuMPJ1lnQJVHvxpg==",
       "requires": {
-        "inherits": "2.0.3",
-        "process-nextick-args": "2.0.0",
-        "readable-stream": "2.3.6"
+        "inherits": "^2.0.1",
+        "process-nextick-args": "^2.0.0",
+        "readable-stream": "^2.3.5"
       }
     },
     "commander": {
@@ -482,8 +482,8 @@
       "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.27.tgz",
       "integrity": "sha512-BAYp9FyN4jLXjfvRpTDchBllDptqlK9I7OsagXCG9Am5C+5jc8eRZHgqb9x500W2OKS14MMlpQc/nmh/aA7TEQ==",
       "requires": {
-        "mdn-data": "1.1.0",
-        "source-map": "0.5.7"
+        "mdn-data": "^1.0.0",
+        "source-map": "^0.5.3"
       }
     },
     "csso": {
@@ -512,7 +512,7 @@
       "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz",
       "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=",
       "requires": {
-        "repeating": "2.0.1"
+        "repeating": "^2.0.0"
       }
     },
     "escape-string-regexp": {
@@ -535,12 +535,12 @@
       "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
       "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
       "requires": {
-        "fs.realpath": "1.0.0",
-        "inflight": "1.0.6",
-        "inherits": "2.0.3",
-        "minimatch": "3.0.4",
-        "once": "1.4.0",
-        "path-is-absolute": "1.0.1"
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
       }
     },
     "globals": {
@@ -553,9 +553,9 @@
       "resolved": "https://registry.npmjs.org/google-closure-compiler-js/-/google-closure-compiler-js-20180204.0.0.tgz",
       "integrity": "sha512-WmPpXyLfpEWKV+Xa+QK4HB6vYlZQSDhIIWi08L77Va5RloZAQKaTXCeg7q4fnuHF0zMYx/s/EsqvQ5S1z9wi0Q==",
       "requires": {
-        "minimist": "1.2.0",
-        "vinyl": "2.1.0",
-        "webpack-core": "0.6.9"
+        "minimist": "^1.2.0",
+        "vinyl": "^2.0.1",
+        "webpack-core": "^0.6.8"
       },
       "dependencies": {
         "minimist": {
@@ -570,7 +570,7 @@
       "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
       "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=",
       "requires": {
-        "ansi-regex": "2.1.1"
+        "ansi-regex": "^2.0.0"
       }
     },
     "home-or-tmp": {
@@ -578,8 +578,8 @@
       "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz",
       "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=",
       "requires": {
-        "os-homedir": "1.0.2",
-        "os-tmpdir": "1.0.2"
+        "os-homedir": "^1.0.0",
+        "os-tmpdir": "^1.0.1"
       }
     },
     "inflight": {
@@ -587,8 +587,8 @@
       "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
       "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
       "requires": {
-        "once": "1.4.0",
-        "wrappy": "1.0.2"
+        "once": "^1.3.0",
+        "wrappy": "1"
       }
     },
     "inherits": {
@@ -601,7 +601,7 @@
       "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
       "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
       "requires": {
-        "loose-envify": "1.3.1"
+        "loose-envify": "^1.0.0"
       }
     },
     "is-finite": {
@@ -609,7 +609,7 @@
       "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz",
       "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=",
       "requires": {
-        "number-is-nan": "1.0.1"
+        "number-is-nan": "^1.0.0"
       }
     },
     "isarray": {
@@ -657,7 +657,7 @@
       "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
       "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
       "requires": {
-        "js-tokens": "3.0.2"
+        "js-tokens": "^3.0.0"
       }
     },
     "magic-string": {
@@ -665,7 +665,7 @@
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.20.0.tgz",
       "integrity": "sha1-JV8H4LFFncANdGNjZ+kgDLtdUp0=",
       "requires": {
-        "vlq": "0.2.3"
+        "vlq": "^0.2.1"
       }
     },
     "mdn-data": {
@@ -678,7 +678,7 @@
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
       "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
       "requires": {
-        "brace-expansion": "1.1.11"
+        "brace-expansion": "^1.1.7"
       }
     },
     "minimist": {
@@ -734,7 +734,7 @@
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
       "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
       "requires": {
-        "wrappy": "1.0.2"
+        "wrappy": "1"
       }
     },
     "os-homedir": {
@@ -752,6 +752,11 @@
       "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
       "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
     },
+    "popper.js": {
+      "version": "1.14.3",
+      "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.14.3.tgz",
+      "integrity": "sha1-FDj5jQRqz3tNeM1QK/QYrGTU8JU="
+    },
     "private": {
       "version": "0.1.8",
       "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz",
@@ -767,13 +772,13 @@
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
       "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
       "requires": {
-        "core-util-is": "1.0.2",
-        "inherits": "2.0.3",
-        "isarray": "1.0.0",
-        "process-nextick-args": "2.0.0",
-        "safe-buffer": "5.1.1",
-        "string_decoder": "1.1.1",
-        "util-deprecate": "1.0.2"
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
       }
     },
     "regenerator-runtime": {
@@ -791,7 +796,7 @@
       "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz",
       "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=",
       "requires": {
-        "is-finite": "1.0.2"
+        "is-finite": "^1.0.0"
       }
     },
     "replace-ext": {
@@ -824,7 +829,7 @@
       "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz",
       "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==",
       "requires": {
-        "source-map": "0.5.7"
+        "source-map": "^0.5.6"
       }
     },
     "sourcemap-codec": {
@@ -842,7 +847,7 @@
       "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
       "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
       "requires": {
-        "safe-buffer": "5.1.1"
+        "safe-buffer": "~5.1.0"
       }
     },
     "strip-ansi": {
@@ -850,7 +855,7 @@
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
       "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
       "requires": {
-        "ansi-regex": "2.1.1"
+        "ansi-regex": "^2.0.0"
       }
     },
     "supports-color": {
@@ -873,8 +878,8 @@
       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.3.16.tgz",
       "integrity": "sha512-FMh5SRqJRGhv9BbaTffENIpDDQIoPDR8DBraunGORGhySArsXlw9++CN+BWzPBLpoI4RcSnpfGPnilTxWL3Vvg==",
       "requires": {
-        "commander": "2.15.1",
-        "source-map": "0.6.1"
+        "commander": "~2.15.0",
+        "source-map": "~0.6.1"
       },
       "dependencies": {
         "source-map": {
@@ -894,12 +899,12 @@
       "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.1.0.tgz",
       "integrity": "sha1-Ah+cLPlR1rk5lDyJ617lrdT9kkw=",
       "requires": {
-        "clone": "2.1.2",
-        "clone-buffer": "1.0.0",
-        "clone-stats": "1.0.0",
-        "cloneable-readable": "1.1.2",
-        "remove-trailing-separator": "1.1.0",
-        "replace-ext": "1.0.0"
+        "clone": "^2.1.1",
+        "clone-buffer": "^1.0.0",
+        "clone-stats": "^1.0.0",
+        "cloneable-readable": "^1.0.0",
+        "remove-trailing-separator": "^1.0.1",
+        "replace-ext": "^1.0.0"
       }
     },
     "vlq": {
@@ -912,8 +917,8 @@
       "resolved": "https://registry.npmjs.org/webpack-core/-/webpack-core-0.6.9.tgz",
       "integrity": "sha1-/FcViMhVjad76e+23r3Fo7FyvcI=",
       "requires": {
-        "source-list-map": "0.1.8",
-        "source-map": "0.4.4"
+        "source-list-map": "~0.1.7",
+        "source-map": "~0.4.1"
       },
       "dependencies": {
         "source-map": {
@@ -921,7 +926,7 @@
           "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz",
           "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=",
           "requires": {
-            "amdefine": "1.0.1"
+            "amdefine": ">=0.0.4"
           }
         }
       }

+ 3 - 2
package.json

@@ -10,8 +10,9 @@
   "author": "Open Mind",
   "license": "ISC",
   "dependencies": {
-    "bootstrap": "^4.0.0",
+    "bootstrap": "^4.1.1",
     "jquery": "^3.3.1",
-    "node-minify": "^2.4.2"
+    "node-minify": "^2.4.2",
+    "popper.js": "^1.14.3"
   }
 }

+ 25 - 0
src/Configuration.php

@@ -0,0 +1,25 @@
+<?php
+/**
+ * @author Open Mind
+ * @date 11/06/2018
+ * @time 12:10
+ */
+
+class Configuration
+{
+  private $options = [
+    'templates' => [
+      '31' => [1004, 1026],
+      '79' => ['tplxslt'],
+    ],
+    'barcode_missing' => 5
+  ];
+
+  /**
+   * Configuration constructor.
+   */
+  public function __construct()
+  {
+  }
+}
+ 

+ 16 - 0
src/Data/Modulo.php

@@ -0,0 +1,16 @@
+<?php
+/**
+ * @author Open Mind
+ * @date 12/06/2018
+ * @time 11:41
+ */
+
+class Modulo
+{
+  /**
+   * Modulo constructor.
+   */
+  public function __construct()
+  {
+  }
+}

+ 16 - 0
src/Data/ModuloParts.php

@@ -0,0 +1,16 @@
+<?php
+/**
+ * @author Open Mind
+ * @date 12/06/2018
+ * @time 11:43
+ */
+
+class ModuloParts
+{
+  /**
+   * ModuloParts constructor.
+   */
+  public function __construct()
+  {
+  }
+}