From 529db55dceb5827bb1d2033ae1e33371e02cd77b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Elgaard=20Larsen?= Date: Sun, 2 Jan 2011 22:41:40 +0100 Subject: [PATCH] Front page, transfer, account/shop creation done --- .htaccess | 8 + Labipay/ActionHandler.pm | 395 ++++++++ Labipay/Auth.pm | 87 ++ Labipay/Funcs.pm | 185 ++++ Labipay/Setup.pm | 11 + ajax/OpenThought.js | 1992 +++++++++++++++++++++++++++++++++++++ ajax/username2accounts.pl | 52 + ajax/util.js | 38 + index.pl | 166 ++++ login.pl | 47 + setup/database.sql | 169 ++++ setup/labipay.ps | Bin 0 -> 20574 bytes setup/labipay_a4.ps | Bin 0 -> 22567 bytes style.css | 120 +++ templates/default.html | 19 + 15 files changed, 3289 insertions(+) create mode 100644 .htaccess create mode 100644 Labipay/ActionHandler.pm create mode 100644 Labipay/Auth.pm create mode 100644 Labipay/Funcs.pm create mode 100644 Labipay/Setup.pm create mode 100755 ajax/OpenThought.js create mode 100755 ajax/username2accounts.pl create mode 100644 ajax/util.js create mode 100755 index.pl create mode 100755 login.pl create mode 100644 setup/database.sql create mode 100644 setup/labipay.ps create mode 100644 setup/labipay_a4.ps create mode 100644 style.css create mode 100644 templates/default.html diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..ea744df --- /dev/null +++ b/.htaccess @@ -0,0 +1,8 @@ +AddHandler cgi-script .cgi .pl + +Options +ExecCGI + +DirectoryIndex index.pl index.cgi index.shtml index.html index.htm + + + diff --git a/Labipay/ActionHandler.pm b/Labipay/ActionHandler.pm new file mode 100644 index 0000000..d63a1f1 --- /dev/null +++ b/Labipay/ActionHandler.pm @@ -0,0 +1,395 @@ +package Labipay::ActionHandler; + +use Labipay::Funcs; +use Labipay::Auth; +use DBI; +use Lingua::EN::Numbers qw(num2en_ordinal); + +require Exporter; +our @ISA = qw(Exporter); +our @EXPORT = qw(handle); + +use strict; +use warnings; + + + +# handle() +# +# Perform any actions from input forms +# +# Arguments: +# cgi - a CGI object +# dbh - a database handle +# user - a hashref that represents the current user (undef if +# no user is logged in) +# actions - a hashref - keys: the names of actions to handle +# values: the page to send to on input errors +# (undef means just return) +# +# Returns: +# A hashref - keys: the names of the handled actions +# values: a hashref with +# status - true on ok, false on fail +# text - a string with any response to +# the user (e.g. ok/error) +# +# Notes: +# On database errors, the error() function is called +sub handle { + my ($cgi, $dbh, $user, $actions) = @_; + + my $res = {}; + my $action = $cgi->param("action"); + + unless ($cgi and $dbh and $actions){ + error(500, "Wrong arguments to handle()"); + } + + # See whether we have anything to do + return $res unless ( $action and exists $actions->{$action} ); + + if ($action eq 'transfer') { + _handle_transfer($res, @_); + } + elsif ($action eq 'create_account') { + _create_account($res, @_); + } + elsif ($action eq 'create_shop') { + _create_shop($res, @_); + } + + return $res; + +} + + +#### +# +# TRANSFER +# +#### +sub _handle_transfer { + my ($res, $cgi, $dbh, $user, $actions) = @_; + + + # Check that we have a logged in user + unless ($user->{id}) { + return _fail( 'transfer', + $actions, + $res, + $cgi, + '

Could not transfer: No user is logged in

'); + } + + # Check permission + unless (permission($user->{id}, 'admin', $dbh)) { + my $sth = $dbh->prepare("SELECT * FROM account WHERE number=?") + or error(500, "Database error: " . $dbh->errstr); + $sth->execute( $cgi->param("from_account") ) + or error(500, "Database error: " . $dbh->errstr); + + my $row = $sth->fetchrow_hashref; + + unless ($row->{owner} == $user->{id}) { + return _fail( 'transfer', + $actions, + $res, + $cgi, + '

You are now allowed to transfer from that account!

'); + } + } + + # Clean up amount + my $amount = $cgi->param("amount") || ''; + $amount =~ s/[^0-9\,\.\-]//g; + + # Handle different commas + if ($amount =~ m/\d\,\d\d\d(\.\d\d)$/){ + $amount =~ s/\,//g; + } + elsif ( $amount =~ m/\d\.\d\d\d(\,\d\d)$/ + or $amount =~ m/^\d*\,\d\d?$/){ + $amount =~ s/\./=/; + $amount =~ s/\,/./; + $amount =~ s/=/,/; + } + + # Force to be a valid number + { + no strict; + no warnings; + $amount += 0; + } + + # Is the amount zero (or 0.00 etc)? + if ( $amount == 0) { + return _fail( 'transfer', + $actions, + $res, + $cgi, + qq|

Please fill out the amount field with a valid number

|); + } + elsif ( $amount < 0 ) { + return _fail( 'transfer', + $actions, + $res, + $cgi, + '

No, you cannot transfer a negative amount. How stupid do you think we are?

'); + } + elsif ( $cgi->param("from_account") == $cgi->param("to_account") ) { + return _fail( 'transfer', + $actions, + $res, + $cgi, + '

It does not make sense to transfer from an account to itself. Please choose another account.

'); + } + + + # Make the transfer as a transaction + $dbh->do("start transaction") + or error(500, "Database error: " . $dbh->errstr); + + $dbh->do("update account set balance = balance - ? where number = ?", + undef, + $amount, + $cgi->param("from_account")) + or _rollback_and_die($dbh); + + + $dbh->do("update account set balance = balance + ? where number = ?", + undef, + $amount, + $cgi->param("to_account")) + or _rollback_and_die($dbh); + + $dbh->do("insert into transfer (from_account, to_account, amount) values (?, ?, ?)", + undef, + $cgi->param("from_account"), + $cgi->param("to_account"), + $amount + ) + or _rollback_and_die($dbh); + + $dbh->do("commit") + or error(500, "Database error - could not commit: " . $dbh->errstr); + + $res->{transfer} = { status => 1, + text => qq|

$amount was transferred

|, + }; + +} + + +#### +# +# CREATE ACCOUNT +# +#### +sub _create_account { + my ($res, $cgi, $dbh, $user, $actions) = @_; + + + # Check that we have a logged in user + unless ($user->{id}) { + return _fail( 'create_account', + $actions, + $res, + $cgi, + '

Could not create account: No user is logged in

'); + } + + my $name = $cgi->param("new_account_name"); + + unless ($name) { + my $sth = $dbh->prepare("SELECT COUNT(number) FROM account WHERE owner = ?"); + if ($sth and $sth->execute("$user->{id}")) { + my $count = $sth->fetchrow_arrayref->[0]; + $name = ucfirst(num2en_ordinal($count+1)); + } + } + $name ||= 'New'; + + $dbh->do("insert into account (name, owner) VALUES (?, ?)", + undef, + $name, + $user->{id} + ) + or error(500, "Database error - could not create account: " . $dbh->errstr); + + $res->{create_shop} = { status => 1, + text => qq|

Account "$name" was created

|, + }; +} + + + +#### +# +# CREATE SHOP +# +#### +sub _create_shop { + my ($res, $cgi, $dbh, $user, $actions) = @_; + + my $add_id_to_name = 0; + + # Check that we have a logged in user + unless ($user->{id}) { + return _fail( 'create_shop', + $actions, + $res, + $cgi, + '

Could not create shop: No user is logged in

'); + } + + my $name = $cgi->param("new_shop_name"); + my $account = $cgi->param("new_shop_account"); + + # Default name + unless ($name) { + $name = '' . ($user->{handle}//'Labitat') . "'s new shop"; + $add_id_to_name = 1; + } + + # Account number check + if ($account) { + # Check that the account exists + $account =~ s/\D//g; + my $sth = $dbh->prepare("SELECT * FROM account WHERE number = ?"); + unless ( $sth and $sth->execute($account) ) { + error(500, "Could not verify account number '$account'"); + } + } + else { + # Select the user's standard account + my $sth = $dbh->prepare("SELECT * FROM account WHERE owner = ? ORDER BY standard DESC, number ASC"); + unless ($sth and $sth->execute($user->{id})){ + error( 500, "Could not find an account to use with the shop: " . $dbh->errstr); + } + my $row = $sth->fetchrow_hashref; + unless ($row) { + return _fail( 'create_shop', + $actions, + $res, + $cgi, + '

You do not have any accounts!

' + ); + } + $account = $row->{number}; + } + + + $dbh->do("start transaction") + or error(500, "Database error: " . $dbh->errstr); + + $dbh->do("insert into shop (name, account) values (?, ?)", + undef, + $name, + $account + ) + or _rollback_and_die($dbh); + + my $sth = $dbh->prepare("SELECT LAST_INSERT_ID()"); + unless ($sth and $sth->execute()){ + _rollback_and_die($dbh); + } + my $shop_id = $sth->fetchrow_arrayref->[0]; + + unless ($shop_id){ + _rollback_and_die($dbh, "Could not get shop ID"); + } + + if ($add_id_to_name) { + $dbh->do("UPDATE shop SET name = CONCAT(name, ' (', ?, ')') WHERE id = ?", + undef, + $shop_id, + $shop_id + ) + or _rollback_and_die($dbh, "Could not change name for shop $shop_id"); + } + + $dbh->do("INSERT INTO shop_admin (shop, person) VALUES (?,?)", + undef, + $shop_id, + $user->{id} + ) + or _rollback_and_die($dbh, "Could not add admin to shop. Giving up"); + + $dbh->do("commit") + or error(500, "Database error - could not commit: " . $dbh->errstr); + + + $res->{create_account} = { status => 1, + text => qq|

Account "$name" was created

|, + }; + +} + + + + +# _fail() +# +# Internal function, handles failure +# +# Arguments: +# action - string with the current action +# actions - incoming hash with actions +# res - a hashref to the result hashref +# cgi - the cgi object +# text - an error message (string) +# +# Returns: +# The result hashref +# +# Notes: +# If an error page is given, calls that error page and exits! +# +sub _fail { + my ($action, $actions, $res, $cgi, $text) = @_; + + $res->{$action}->{status} = 0; + $res->{$action}->{text} = $text; + + unless ($actions->{$action}) { + return $res; + } + + # TODO: Return to proper page + + + error(401, $text); +} + + +# _rollback_and_die() +# +# Makes a transaction rollback and calls error() +# +# Arguments: +# dbh - a database handle +# [msg] - a message string (optional, default 'Database error') +# +# Returns: +# Never returns, exits +# +# Notes: +# None +sub _rollback_and_die { + my ($dbh, $msg) = @_; + + $msg //= 'Database error'; + + my $errstr = $dbh->errstr; + + $dbh->do("rollback") + or error(500, + sprintf("Database error - could not even rollback: %s (original error was: %s - %s)", + $dbh->errstr, + $msg, + $errstr) + ); + + error(500, "$msg: $errstr
Rolled back"); +} diff --git a/Labipay/Auth.pm b/Labipay/Auth.pm new file mode 100644 index 0000000..1cdd777 --- /dev/null +++ b/Labipay/Auth.pm @@ -0,0 +1,87 @@ +package Labipay::Auth; + +require Exporter; +our @ISA = qw(Exporter); +our @EXPORT = qw(authenticate permission); + +use Labipay::Funcs; + +# authenticate() +# +# Handles authentification of the web user. +# This can be done by login, cookies, or other means +# +# Arguments: +# cgi a CGI object +# dbh a database handle +# [return] URL of the page to return to after login (optional) +# +# Returns: +# A hashref with the following keys: +# id - User id +# name - User's real name +# handle - User handle (user name) +# email - User email (if known) +# authfields - an arrayref of [name, value] pairs to insert in all forms +# +# undef on error and when the user could not be autheticated. +# +# Notes: +# If the "return" argument is given, this function will _not_ return. +# Instead, it will refer the user to a login page and exit. +# The login page will be given the "return" argument as the URL to +# send the user back to after giving his credentials. +sub authenticate { + my ($cgi, $dbh, $return) = @_; + + return undef unless $cgi && $dbh; + + # For now, just check whether I am me + if ( ( $cgi->param("auth_user") || '' eq 'Elhaaard' + and $cgi->param("auth_password") || '' eq 'letmein') + or ( $cgi->param("auth_userid") || '' eq '2' + and $cgi->param("auth_session") || '' eq '8khjdwq8y32e') + ) { + return { id => 2, + name => "Jørgen Elgaard Larsen", + handle => "Elhaard", + email => 'jel@elgaard.net', + authfields => [ [ auth_session, '8khjdwq8y32e' ], [auth_userid, 2] ], + }; + } + + # If we get here, the user could not be authenticated + return undef unless $return; + my $args = "return=$return"; + $args .= "&auth_user=" . $cgi->param("auth_user") if $cgi->param("auth_user"); + print ${apply_template( { title => "Please login", + page => qq|

Please login here

|, + headers => { Location => qq|login.pl?$args| }, + } + ) + }; + exit; + +} + + +# permission() +# +# Find out whether a given user has a given permission +# +# Arguments: +# userid a user id (integer) +# permission the permission to check (string, e.g. "admin") +# dbh a database handle +# +# Returns: +# True or false +# +# Notes: +# None +sub permission { + my ($userid, $permission, $dbh) = @_; + + return ($userid < 1); +} + diff --git a/Labipay/Funcs.pm b/Labipay/Funcs.pm new file mode 100644 index 0000000..768edca --- /dev/null +++ b/Labipay/Funcs.pm @@ -0,0 +1,185 @@ +package Labipay::Funcs; + +use Labipay::Setup; +use DBI; + +require Exporter; +our @ISA = qw(Exporter); +our @EXPORT = qw(db_connect apply_template make_auth_fields error); + + + + +# db_connect() +# +# Connect to the database +# +# Arguments: +# None +# +# Returns: +# A database handle, undef on error +# +# Notes: None +sub db_connect { + + my $dbh = DBI->connect($Labipay::Setup::db_dsn, + $Labipay::Setup::db_user, + $Labipay::Setup::db_pw); + + return $dbh; +} + + +# apply_template() +# +# Applies a template to the data to make a complete HTTP response +# +# Arguments: +# data A hashref with the data to fill the template +# The standard template accepts the following keys: +# page - The content of the body +# title - The title of the page +# head - A text to insert in the HTML HEAD +# headers - An arrayref with HTTP headers to add +# +# [template] A string with the template to use. (Optional) +# If the template is a file URL, the file is used. +# If the template is a string beginning with "template:", +# the string after the colon is taken to be the symbolic +# name of a template to read. +# +# Returns: +# A reference a string with the content. +# +# Notes: +# On error, a default template is used. +sub apply_template { + my ($data, $template) = @_; + + # If no template has been set, use the default + $template //= "template:$Labipay::Setup::default_template"; + + if ($template =~ m/^template:(.+)$/){ + if ( open TEMP, "<$Labipay::Setup::template_dir/$1" ){ + $template = join "", ; + } + else { + $template = undef; + } + } + elsif ( $template =~ m/^file:\/\/(.+)$/){ + if ( open TEMP, "<$1" ){ + $template = join "", ; + } + else { + $template = undef; + } + } + + unless (defined $template) { + # Make an emergency template + + $template = join "", ; + } + + # Make headers + my $headers = "Content-Type: " . + ($data->{headers}->{"Content-Type"} || "text/html") . + "\n"; + + foreach my $header ( grep { $_ ne 'Content-Type' } keys %{$data->{headers}} ){ + $headers .= "$header: $data->{headers}->{$header}\n"; + } + + + # Apply template + foreach my $key ( keys %$data ) { + next if $key eq 'headers'; + + my $uckey = uc $key; + + $template =~ s//$data->{$key}/g; + } + $template =~ s///g; + + $template = "$headers\n$template"; + + return \$template; +} + + +# make_auth_fields() +# +# Create field for authentification +# +# Arguments: +# user - as userinfo hashref as returned by Auth +# for_get - If true, make fields suitable for a GET string +# (e.g. "foo=bar&baz=quux"). If false (default), +# make fields suitable for a form (e.g. ""). +# +# Returns: +# A string with any relevant fields +# +# Notes: +# None +sub make_auth_fields { + my ($user, $for_get) = @_; + + my $res = ''; + + if ( $user + and ref $user eq 'HASH' + and $user->{authfields} + and ref $user->{authfields} eq 'ARRAY' + ){ + + if ($for_get) { + $res .= join '&', map { "$_->[0]=$_->[1]" } @{$user->{authfields}}; + } + else { + foreach my $pair (@{$user->{authfields}}) { + + $res .= qq|\n| + } + } + } + return $res; +} + + +# error() +# +# Make an error page and exit. +# +# Arguments: +# status - HTTP status code (e.g. 500) +# msg - A string with the error message +# +# Returns: +# Does not return (exits) +# +# Notes: +# Exits +sub error { + my ($status, $msg) = @_; + + print ${apply_template( { title => "Error $status", page => qq|

$msg

|, } )}; + + exit; +} + + +__DATA__ + + + Error: <!--TITLE--> + + + +

+ + + + diff --git a/Labipay/Setup.pm b/Labipay/Setup.pm new file mode 100644 index 0000000..6556092 --- /dev/null +++ b/Labipay/Setup.pm @@ -0,0 +1,11 @@ +package Labipay::Setup; + + +our $db_dsn = "DBI:mysql:database=labipay;host=localhost"; +our $db_user = 'labipay'; +our $db_pw = 'whatever'; + + +our $template_dir = 'templates'; +our $default_template = 'default.html'; + diff --git a/ajax/OpenThought.js b/ajax/OpenThought.js new file mode 100755 index 0000000..403a8d0 --- /dev/null +++ b/ajax/OpenThought.js @@ -0,0 +1,1992 @@ +// +// OpenThought +// +// Author: Eric Andreychek +// +// http://openthought.net +// +// The contents of this file are Copyright (c) 2000-2007 Eric Andreychek. All +// rights reserved. For distribution terms, please see the LICENSE file +// included with the OpenThought application. + + +/* +TODO +* Optimize for browsers + - no need for channels with XMLHttpRequest browsers + - limit channels for IE4/NS4 + - Better browser detection +* Add more caching, we're doing some work multiple times +* Add support for highlighting multiple items in a select-multiple + - Possible via selectbox_highlight or selectbox_duplicate_value options + + +*/ +function OpenThoughtConfig() { +////////////////////////////////////////////////////////////////////////////// +// +// Config section +// +// Change any of the following to your preference + +/* + Enable a log window so you can see what's going on behind the scenes. If + something in your app isn't working, try enabling this. This can be very + useful for debugging, but you probably want it disabled while your app is in + production. This, of course, won't work if your popup blocking software + doesn't allow popups from the site you're running your application from. +*/ +this.log_enabled = false; + +/* + What log level to run at. You have the ability to enable lots of debugging + output, only serious errors, and various levels in between. + * options: debug, info, warn, error, fatal +*/ +this.log_level = "debug"; + +/* + Require what features in a browser. If the feature is missing, go to the + corresponding url. OpenThought itself always requires a 4.0 browser DOM as + a minimum, but your application may have more specific requirements. + * options: 40dom -- Needs to have a basic 4.0 browser DOM (needed for OT) + htmlrewrite -- Needs to support innerHTML + xmlhttp -- Must support XmlHttpRequest or XMLHTTP + iframe -- Must support iframes (all but NS4) + layer -- Must support layers (only NS4) +*/ +this.require = { "40dom" : "http://openthought.net?rm=unsupported_browser" }; + +/* EXPERIMENTAL + The default request type for communications with the server. This can be + overridded at any time by passing in either GET or POST as the first + parameter to CallUrl(). The default is GET. POST is not well tested. + * options: GET or POST +*/ +this.http_request_type = "GET" + +/* + The type of channel to use for communicating with the this.browser. Normally, + OpenThought will attempt to use the XMLHttpRequest or XMLHTTP functions + available in recent browsers, then fall back to iframes if the browser + doesn't support those newer options. However, XMLHttpRequest and XMLHTTP + have a limitation -- for any given request, they can only parse data sent + from the server once, doing so after the request is complete. Iframes parse + data as it's sent from the server, and can do so as many times as desired + for any given request. XMLHttpRequest/XMLHTTP are fine for most + uses, but some applications may benefit from being able to have the browser + receive data a number of times throughtout a single request (ie, irc and + other realtime chat applications, or a log tailing app). + * options: auto or iframe +*/ +this.channel_type = "auto" + +/* + Normally, the channel used to communicate with the server is invisible. The + curious may wish to see whats going on inside it (or perhaps need it for + debugging). Enabling the following will make the channel visible. This only + works when the channel is an iframe. + + For now, the only way to see the JavaScript being inserted into the channel + is to right-click the visible iframe and hit 'View Source'. +*/ +this.channel_visible = false + +/* + When using iframes and layers, the typical way to send data to the server + involves using a 'document.location.replace()'. This means the requests + aren't being stored in the browser history. So, the back button will take + you to the previous *page*, not the previous AJAX request. This is often + what people want. This sometimes isn't what people want :-) Set to 'true' + to not add AJAX requests to the browser's history, set to 'false' to have + them added to the history. + * options: true or false +*/ +this.url_replace = true; + +/* + During any call to the server (via CallUrl and FetchHtml), assume the script + is located in this directory (ie, the file/dir you pass in is relative to + this path). If there's no trailing slash, it will add one. This config + option can be overridden by beginning the url with 'http' or '/'. + +*/ +this.url_prefix = ""; + +/* + Aside from Netscape 4, all browsers which receive text into a select + box resize that select box to the width of the longest entry. + Select box resizing is neat, but sometimes it ends up being much to + big, and can adversly affect other parts of your visual layout. + This option allows us to modify the size of text going into a + select box, so the browser doesn't make the select box too big. + * options: Any number, or 0 for unlimited resizing +*/ +this.selectbox_max_width = "30" + +/* + If the text in a selectbox needs to be resized to fit (due to + selectbox_max_width), replace the removed text with the following string to + make it clear that the string was trimmed. +*/ +this.selectbox_trim_string = ".." + +/* + Sending in an array reference to a select list will add a single row. Do + you want that row to be appended to the existing list of items, or to + overwrite all the existing data in the select list? The default mode is + to append. + * options: append or overwrite +*/ +this.selectbox_single_row_mode = "append" + +/* + Sending in a reference to an array of arrays to a select list will add + multiple rows. Do you want that data to be appended to the existing list + of items, or to overwrite all the existing data in the select list? The + default mode is to overwrite. + * options: append or overwrite +*/ +this.selectbox_multi_row_mode = "overwrite" + +/* NOTE: selectbox_duplicate_value NOT YET IMPLEMENTED + TODO: For this to work, we'll probably need to send the data as an array of + arrays + What to do if items sent into a selectbox has a value of an item which + already exists in that selectbox. + * options: + - smart: If data is received, where a value in it matches a value + already in the select list, highlight the item. If the value + matches, but the text is different, change the text currently in the + select list to match. If either of the above is done, and we receive + additional data for the list, always append rather than overwrite. + If none of the above is the case, fall back to checking the current + select list mode (append or overwrite). + - default-to-mode: Default to the current value of the single_row_mode + or multi_row_mode, and display the item as a new element in the list. +this.selectbox_duplicate_value = "smart" +*/ + +/* + The value a checkbox will return if it is checked, and no value is + assigned to the checkbox (via the value= attribute). This is *not* + applied to radio buttons, radio buttons return their value attribute, a + required attribute, when true. + * options: Any string +*/ +this.checkbox_true_value = "1" + +/* + The value a checkbox will return if it isn't checked. This is also + applied to radio buttons if none of the radio buttons are selected. + * options: Any string +*/ +this.checkbox_false_value = "0" + +/* + The value a group of radio buttons will return if none of them are + selected. + * options: Any string +*/ +this.radio_null_selection_value = "" + + +/* + Should new data being sent to the browser overwrite, or be appended to, + existing data? This does not apply to select boxes, checkboxes, radio + buttons, and images. + * options: append or overwrite + +*/ +this.data_mode = "overwrite" + + + +// +// End Config section (ie, stop changing stuff) +////////////////////////////////////////////////////////////////////////////// + + + + + + +// If there is a function named "OpenThoughtConfigLocal", we've been provided +// with a customized set of config options.... use them overtop of the ones +// listed here. To work, it *must* be loaded before this file is, it won't be +// noticed otherwise. +var local_options = ""; + +if(typeof(OpenThoughtConfigLocal) == "function") { + local_options = new OpenThoughtConfigLocal(); + if (local_options != "") { + for ( option in local_options ) { + this[option] = local_options[option]; + } + } +} + +// This will inform us if a given option is included in our list of +// requirements +this.Require = function(param) { + if (this.require[param]) { + return this.require[param]; + } +} + +} +// Yummy +var OpenThought = new OpenThought(); +OpenThought.browser.VerifyRequirements(); + +////////////////////////////////////////////////////////////////////////////// +// +// OpenThought Class +// +// Okay, this code barely fits on the screen as is... we're just going to have +// to remember that the following is within the 'OpenThought' class, as I +// really don't want to have to push everything over another 4 spaces. I may +// change my mind on that :-) + +function OpenThought() { +this.config = new OpenThoughtConfig(); +this.browser = new OpenThoughtBrowser(this.config); +this.log = new OpenThoughtLog(this.config.log_enabled, this.config.log_level); +this.communicator = new OpenThoughtCommunicator(this.browser, this.config, this.log); +this.util = new OpenThoughtUtil(); + +var undefined; + +// Change to an alternate url, typically because something isn't supported +this.Url = function(url, replace) { + if (replace) { + location.replace( url ); + } + else { + location.href = url; + } +} + +// Call a url in the background +this.CallUrl = function() { + + this.log.info("Received ajax event"); + + var eventType = "data"; + + return this.Send(arguments, "ajax"); +} + +// This loads a new page in the content frame +this.FetchHtml = function() { + + this.log.info("Received ui event"); + + var eventType = "ui"; + + return this.Send(arguments, "ui"); +} + +// Called data has arrived from the server +this.ServerResponse = function(content) { + + this.log.info("Received response from server"); + + // Display everything if we received a decent response + if (content != null) + { + for (field_name in content) { + this.SetElement(field_name, content[field_name]); + } + this.log.info("All fields filled. -============================-"); + + } + + return; +} + +// Called by the server whenever it's finished sending the response +this.ResponseComplete = function(channel) { + + return this.communicator.Complete(channel); +} + + +// Digs through the browsers DOM hunting down a particular element +this.FindElement = function(element, doc) { + + this.log.debug("Searching for element [" + element + "]"); + + var p,i,object; + + if(!doc) { + doc = document; + } + + if((p = element.indexOf("?")) > 0 && parent.frames.length) { + doc = parent.frames[element.substring(p+1)].document; + element = element.substring(0,p); + } + + if(!(object = doc[element]) && doc.all) { + object = doc.all[element]; + } + + for (i=0; !object && i < doc.forms.length; i++) { + object = doc.forms[i][element]; + } + + for(i=0; !object && doc.layers && i < doc.layers.length; i++) { + object = this.FindElement(element, doc.layers[i].document); + } + + if(!object && document.getElementById) { + object = doc.getElementById(element); + } + + if (this.config.log_enabled) { + if (object) { + if((!object.type) && (object.length > 0)) { + object.type="radio"; + } + + if(( object.type ) && (object.type != "button" )) { + this.log.debug("Found form element [" + object.name + "] of type [" + object.type + "]."); + } + else if( typeof(object.innerHTML) == "string") { + this.log.debug("Found HTML element [" + object.id + "] of type [" + object.tagName + "]."); + } + else if((object["tagName"]) && (object["tagName"] == "IMG")) { + this.log.debug("Found image element [" + object.name + "] with src [" + object.src + "]."); + } + else { + this.log.debug("It's here, but I dunno what it is"); + } + } + else { + // We'll log as error in the calling function + this.log.info("Unable to find [" + element + "]."); + } + } + + return object; +} + +// Initialize and populate the Select list +this.FillSelect = function(element, data) { + + this.SelectboxTextTrim = function(text) { + if ((this.browser.version != "NS4") && (this.config.selectbox_max_width != 0)) { + if (text && text.length > this.config.selectbox_max_width) { + text = text.substr(0,this.config.selectbox_max_width) + this.config.selectbox_trim_string; + } + } + return text; + } + + var i; + + // Prevent ourselves from having to do the element.options lookup too many + // times + var element_options = element.options; + + // Null means we want to clear out the select list + if (data == null) { + this.log.info("Received null, clearing [" + element.name + "]"); + while (element_options.length) element_options[0] = null; + } + + // If sent a string, and not an array, we just need to highlight an + // existing item in the list, and not add anything + else if(typeof data == "string") { + this.log.debug("Attempting to highlight [" + data + "] in [" + element.name + "]"); + for (i=0; i < element_options.length; i++) { + if( element_options[i].value == data ) { + this.log.info("Found [" + data + "] in [" + element.name + "], highlighting."); + element.selectedIndex = i; + } + } + + } + // Actually add the items we were sent to the list + else { + // Clear any current OPTIONS from the SELECT (but only if overwrite is + // selected) + if(( (typeof data[0] == "string") && (data[0] != "") && + ( data.constructor != Object ) && + ( this.config.selectbox_single_row_mode == "overwrite" )) || + + ( (typeof data[0] != "string") && (data[0] != "" ) && + ( data.constructor != Object ) && + ( this.config.selectbox_multi_row_mode == "overwrite" ))) { + + while (element_options.length) element_options[0] = null; + if((data.length == 1) && (data[0] == "")) { + return; + } + } + + if ( data.constructor == Array || + data.constructor.toString().match(/Array/) ) { + // For each record... + for (var i=0; i < data.length; i++) { + var text; + var value; + + if (typeof data[0] == "string") { + text = data[0]; + value = data[1]; + + if (data[1] == "") { + value = text; + } + i++; + } + else if (data[i].constructor == Object || + data[i].constructor.toString().match(/Object/)) { + for (text in data[i]) { + value = data[i][text]; + } + } + else if (data[i].constructor == Array || + data[i].constructor.toString().match(/Array/) ) { + text = data[i][0]; + value = data[i][1]; + + if (data[i][1] == "") { + value = text; + } + } + else { + this.log.error("Unknown data type sent into select list."); + } + + text = this.SelectboxTextTrim(text); + element_options[element_options.length] = new Option(text, value); + } + } + else if (data.constructor == Object || + data.constructor.toString().match(/Object/) ) { + var text; + var value; + for (text in data) { + value = data[text]; + } + text = this.SelectboxTextTrim(text); + element_options[element_options.length] = new Option(text, value); + } + else { + this.log.error("Unknown data type sent into select list"); + } + this.log.info("Adding data to [" + element.name + "]."); + } +} + +// Put values into html +this.FillHtml = function(element, data) +{ + if ( data == null ) { + this.log.info("Received null, emptying [" + element.id + "]."); + element.innerHTML = ""; + } + else { + if (this.config.data_mode == "append") { + this.log.info("Filling [" + element.id + "] with [" + data + "] (append)."); + if (typeof(element.outerHTML) == "string") { + element.outerHTML = element.outerHTML.replace(/(<.*?>)(?:.|\n)*(<\/.*?>)/,"$1"+element.innerHTML+data+"$2"); + } + else { + element.innerHTML += data; + } + } + else { + this.log.info("Filling [" + element.id + "] with [" + data + "] (overwrite)."); + if (typeof(element.outerHTML) == "string") { + element.outerHTML = element.outerHTML.replace(/(<.*?>)(?:.|\n)*(<\/.*?>)/,"$1"+data+"$2"); + } + else { + element.innerHTML = ""; + element.innerHTML = data; + } + } + } +} + +// Put values into a text form field +this.FillText = function(element, data) +{ + if ( data == null ) { + this.log.info("Received null, emptying [" + element.name + "]."); + element.value = ""; + } + else { + if (this.config.data_mode == "append") { + this.log.info("Filling [" + element.name + "] with [" + data + "] (append)."); + element.value += data; + } + else { + this.log.info("Filling [" + element.name + "] with [" + data + "] (overwrite)."); + element.value = data; + } + } + +} + +// Select or unselect a checkbox form field +this.FillCheck = function(element, data) +{ + this.log.info("Filling [" + element.name + "] with [" + data + "]."); + + if(((data == null) || (data == "false") || (data == "FALSE") || (data == "False") || + (data == "unchecked") || (data < "1") || + (data == this.config.checkbox_false_value )) && + (data != this.config.checkbox_true_value )) + { + element.checked = false; + } + else + { + element.checked = true; + } +} + +// Select a radio button +this.FillRadio = function(element, value) +{ + this.log.info("Filling [" + element.name + "] with [" + value + "]."); + + for(var i=0; i 0)) + { + object.type="radio"; + } + + if(( object.type ) && (object.type != "button" )) { + switch (object.type) + { + case "select": + case "select-one": + case "select-multiple": + this.FillSelect(object,fieldValue); + break; + + case "text": + case "password": + case "textarea": + case "hidden": + case "file": + this.FillText(object, fieldValue); + break; + + case "checkbox": + this.FillCheck(object, fieldValue); + break; + + case "radio": + this.FillRadio(object, fieldValue); + break; + } + } + else if( typeof(object.innerHTML) == "string") { + this.FillHtml(object, fieldValue); + } + else if((object["tagName"]) && (object["tagName"] == "IMG")) { + this.FillImage(object, fieldValue); + } + else { + this.log.error("Error: received unknown field '" + fieldName + "'"); + return false; + } + + return true; +} + + +// Retrieves the current value and type of an element +this.GetElement = function(element, type) +{ + var element_value; + var element_type; + + var object = this.FindElement(element); + + if( !object ) { + this.log.error("Error: cannot find an object named '" + element + "'\n" + + "Be sure you spelled it correctly. Also, your form " + + "elements must be within form tags."); + return false; + } + + // This is kinda silly, but radio buttons don't seem to return an + // object.type in some browsers + if((!object.type) && (object.length > 0)) { + object.type="radio"; + } + + if( object.type ) { + element_type = "fields"; + + switch (object.type) + { + case "text": + case "password": + case "textarea": + case "hidden": + case "file": + element_value = this.TextValue(object); + break; + + case "select": + case "select-one": + element_value = this.SelectValue(object); + break; + + case "select-multiple": + element_value = this.SelectMultipleValue(object); + break; + + case "checkbox": + element_value = this.CheckboxValue(object); + break; + + case "radio": + element_value = this.RadioValue(object); + break; + } + } + else if( typeof(object.innerHTML) == "string") { + element_type = "html"; + element_value = object.innerHTML; + } + else if((object["tagName"]) && (object["tagName"] == "IMG")) { + element_type = "images"; + element_value = object.src; + } + + if (type && type == element_type) { + return [ element_type, element_value ]; + } + else if (type && type != element_type) { + return [false, false]; + } + else { + return [ element_type, element_value ]; + } +} + +// Value of text/password/textarea/hidden/file (filename only) fields +this.TextValue = function(element) { + return element.value; +} + +// Figure out which option is selected in our Select list +this.SelectValue = function(element) +{ + if(element.selectedIndex >= 0) { + return element.options[element.selectedIndex].value; + } + else { + return ""; + } +} + +// Figure out which options are selected in our Select Multiple list +this.SelectMultipleValue = function(element) +{ + var values = new Array(); + + if(element.selectedIndex >= 0) { + for(var i=0; i < element.length; i++) { + if ( element.options[i].selected == true ) { + values[values.length] = element.options[i].value; + } + } + return values; + } + else { + return ""; + } +} + +// Figure out which option is selected in our checkbox +this.CheckboxValue = function(element) +{ + if(element.checked == true) + { + if(element.value == "on") + { + return this.config.checkbox_true_value; + } + else + { + return element.value; + } + } + else + { + return this.config.checkbox_false_value; + } + +} + +// Figure out which option is selected in our radio button +this.RadioValue = function(element) +{ + var values = new Array(); + + // This occurrs if there is only one radio button + if (element.length == null ) { + if(element.checked == true) { + values[values.length] = element.value; + } + } + // More than one button + else { + + for (var i=0, n=element.length; i < n; i++) + { + if(element[i].checked == true) + { + values[values.length] = element[i].value; + } + } + } + + if ( values.length > 0 ) { + if ( values.length == 1 ) { + return values[0]; + } + else { + return values; + } + } + else { + return this.config.radio_null_selection_value; + } +} + +// Takes a fieldname as an argument, and gives that field the focus +this.Focus = function(element) +{ + var object = this.FindElement(element) + + // If no object is found with the name "element", then either they typed in + // wrong, or it's an anchor. There's no way to know for sure, so let's + // guess the latter. This should make debugging fun. + + if( !object ) { + if ( document.anchors[element] ) { + this.log.info("Jumping to anchor tag ['" + element + "']."); + location.hash = "#" + element; + } + else { + this.log.error("Can't seem to find the element '" + element + "', unable to focus."); + } + } + else { + this.log.info("Focusing form element ['" + element + "']."); + object.focus(); + } +} + +// Do the call to the server +this.Send = function(args, eventType) { + + this.log.debug("Preparing to send."); + + var params = this.ParseParams(args); + + return this.communicator.Beam(params.url, params.args, params.method, eventType ); +} + +this.ParseParams = function(args) { + var start_index = 0; + var params = new Object; + params["args"] = new Array; + + if (args[0] == "GET") { + params["method"] = args[0]; + params["url"] = args[1]; + start_index = 2; + } + else if (args[0] == "POST") { + params["method"] = args[0]; + params["url"] = args[1]; + start_index = 2; + } + else { + params["method"] = this.config.http_request_type; + params["url"] = args[0]; + start_index = 1; + } + + for (var i = start_index; i < args.length; i++) { + // We allow arrays for parameters, test for them here + if (typeof(args[i]) == "object" && args[i].length) { + params["args"] = params["args"].concat(args[i]); + } + else { + params["args"][params["args"].length] = args[i]; + } + } + + return params; +} + +} + +////////////////////////////////////////////////////////////////////////////////// +// +// OpenThoughtCommunicator Class +// + +function OpenThoughtCommunicator(browser_l, config_l, log_l) { +this.browser = browser_l; +this.config = config_l; +this.log = log_l; +var channels = new Array; + +this.Beam = function(url, args, method, eventType) { + + var params; + + // Add the url_prefix if we have one + if (( this.config.url_prefix != "" ) && + ( url.substr(0, 6) != "http://" ) && + ( url.substr(0, 1) != '/' )) { + + seperator = ""; + + if (this.config.url_prefix.substr(this.config.url_prefix.length-1) != '/') { + seperator = '/'; + } + url = this.config.url_prefix + seperator + url; + } + + + // If there's no ? in the url to seperate the host from the params, add one + if (method == "GET") { + if(url.indexOf("?") == -1) { + url = url + "?"; + } + else { + url = url + "&"; + } + + // The browser generally handles this sanely for UI events + if (eventType != "ui") { + // Die cache die + var d = new Date(); + url += "_u=" + d.getTime() + '&'; + } + + } + + // ui events use "href", which keeps the page in the history, allowing the + // back button to work + if (eventType == "ui") { + // TODO: Allow POST via FetchHtml + this.log.info("Send (ui): " + params + " to " + url); + if (params == "") { + url = url.substring(0, url.length-1); + } + params = this.Hash2GetParam(this.GenParams(args, eventType )); + document.location.href = url + params; + return; + } + + var channel_info = this.getOpenChannel(); + var type = channel_info["type"]; + var channel = channel_info["channel"]; + + // If using XMLHttp, the parameters still look like a typical query string, + // we don't need to fake a form submission (yay) + if (method == "POST" && type == "XMLHttp" && eventType != "ui") { + params = this.Hash2GetParam(this.GenParams(args, eventType )); + } + else if (method == "POST") { + params = this.Hash2PostParam(this.GenParams(args, eventType )); + } + else { + params = this.Hash2GetParam(this.GenParams(args, eventType )); + } + + this.log.info("Sending AJAX Request --"); + this.log.info("Url: " + url); + this.log.info("Params: " + params); + + // Good, we seem to be able to use XMLHTTPRequest + if (type == "XMLHttp" ) { + if (method == "GET") { + url = url + params; + } + channel.open(method, url, true); + channel.onreadystatechange=function() { + // See if the readyState is 'loaded' + if (channel.readyState == 4) { + // only eval if 'OK', otherwise silly things will happen + if (channel.status == 200 && channel.responseText) { + + // Ignore the \n|;; + +# Handle actions +my $handler_response = handle($q, $dbh, $user, {'transfer' => undef, + create_shop => undef, + create_account => undef, + } + ); + + +# Find account info +my $total_balance = 0; + +my $auth_fields = make_auth_fields($user); +my $auth_get_fields = make_auth_fields($user, 1); +my $my_accounts_options = ''; + +my $sth = $dbh->prepare("SELECT * FROM account WHERE owner = ? order by standard DESC, number ASC"); +error(500, "No contact to database: " . $dbh->errstr) unless $sth; +$sth->execute($user->{id}) or error(500, $dbh->errstr); + +# If no account has been defined for this user, create one +unless ($sth->rows) { + $dbh->do("INSERT INTO account (owner, name, standard) values (?, 'Primary', 1)", undef, $user->{id}) or error(500, "Could not create account: " . $dbh->errstr); + + # Re-get + $sth->execute($user->{id}); +} + +$rightbox .= qq| + \n|; + +while (my $account = $sth->fetchrow_hashref){ + my $sel = ord($account->{standard})==0 ? '' : 'selected="selected"'; + $my_accounts_options .= qq|\n|; + + + my $balanceclass = ($account->{balance} < 0) ? ' class="negative"' : ''; + $rightbox .= sprintf(qq|\n|, + $account->{number}, + $auth_get_fields, + $account->{name}, + $account->{balance} + ); + $total_balance += $account->{balance}; +} +my $balanceclass = ($total_balance < 0) ? ' class="negative"' : ''; +$rightbox .= sprintf( qq|\n|, + $total_balance); + + +# "Create account" button +$rightbox .= qq||; +$rightbox .= qq|
Accounts
%s%01.2f
Total:%01.2f
|; +$rightbox .= $auth_fields; +$rightbox .= qq||; +$rightbox .= qq||; +$rightbox .= qq|

\n|; + +# TODO: Add javascript to ask for account name + + +# Shop info +$sth = $dbh->prepare("SELECT shop.* FROM shop, shop_admin WHERE shop_admin.shop = shop.id and shop_admin.person = ? order by name ASC"); +error(500, "No contact to database: " . $dbh->errstr) unless $sth; + +$sth->execute($user->{id}); + +$rightbox .= qq|Shops
\n|; + +while (my $shop = $sth->fetchrow_hashref){ + $rightbox .= sprintf(qq|%s
\n|, + $shop->{id}, + $auth_get_fields, + $shop->{name}, + ); +} + +# "Create shop" button +$rightbox .= qq|
|; +$rightbox .= make_auth_fields($user); +$rightbox .= qq||; +$rightbox .= qq||; +$rightbox .= qq||; + +# TODO: Add javascript to ask for shop name and account +$rightbox .= qq|
|; + + +if ($total_balance < 0) { + $page .= sprintf( qq|

You owe money!
Please insert at least %01.2f on your account.

|, + 0 - $total_balance + ); +} + + +# Buy button +$page .= qq|

Go shopping

+ $auth_fields +
|; + + +# Transfer +$head .= qq|\n|; +$page .= '

Transfer

'; +$page .= $handler_response->{transfer}->{text} || ''; +$page .= qq|
+ Transfer a sum of + + from my account +
+ to
+ + + my account
+ + + user + 's account + + $auth_fields +
+ + +
+ + |; + + + +print ${apply_template( { title => $title, + page => qq|
$rightbox
$page|, + head => $head, + } + ) + }; + + +exit; diff --git a/login.pl b/login.pl new file mode 100755 index 0000000..d98f64b --- /dev/null +++ b/login.pl @@ -0,0 +1,47 @@ +#!/usr/bin/perl -Tw + +use strict; +use warnings; + +use lib '.'; + +use Labipay::Funcs; +use Labipay::Auth; +use CGI; + + +my $q = new CGI; +my $dbh = db_connect(); + + +my $user = authenticate($q, $dbh); +my $return = $q->param("return"); +my $title = "Login"; + +my $page = qq|
\n|; +if ($user) { + foreach my $pair ( @{$user->{authfields}} ) { + $page .= qq|\n|; + } + $page .= qq||; + $title = ''; +} +else { + my $name = $q->param("auth_user") || ''; + if ($name){ + $page .= qq|

Login incorrect

\n|; + } + + $page .= qq|Username:
+ Password: + + |; +} +$page .= qq|
|; + + + +print ${apply_template( { title => $title, page => $page } )}; + + +exit; diff --git a/setup/database.sql b/setup/database.sql new file mode 100644 index 0000000..11ef72a --- /dev/null +++ b/setup/database.sql @@ -0,0 +1,169 @@ + + +CREATE TABLE `account` ( + `number` int(11) NOT NULL, + `owner` int(11) NOT NULL, + `name` varchar(255) NOT NULL DEFAULT 'Unnamed account', + `balance` decimal(10,2) NOT NULL DEFAULT '0.00', + PRIMARY KEY (`number`), + KEY `owner` (`owner`), + CONSTRAINT `account_ibfk_1` FOREIGN KEY (`owner`) REFERENCES `person` (`id`) +); + + + +CREATE TABLE `card` ( + `id` int(11) NOT NULL, + `account` int(11) NOT NULL, + `hash` varchar(256) NOT NULL DEFAULT '', + `name` varchar(255) NOT NULL DEFAULT 'Unnamed card', + PRIMARY KEY (`id`), + KEY `hash` (`hash`), + KEY `account` (`account`), + CONSTRAINT `card_ibfk_1` FOREIGN KEY (`account`) REFERENCES `account` (`number`) +); + + + +CREATE TABLE `category` ( + `id` int(11) NOT NULL, + `name` varchar(255) NOT NULL DEFAULT 'Unnamed', + `parent` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `parent` (`parent`), + CONSTRAINT `category_ibfk_1` FOREIGN KEY (`parent`) REFERENCES `category` (`id`) +); + + + +CREATE TABLE `person` ( + `id` int(11) NOT NULL, + `name` varchar(255) DEFAULT NULL, + `username` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`) +); + + + + + +CREATE TABLE `purchase` ( + `id` int(11) NOT NULL, + `product` int(11) NOT NULL, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `amount` decimal(10,3) NOT NULL, + `price` decimal(10,2) NOT NULL, + PRIMARY KEY (`id`), + KEY `product` (`product`), + KEY `time` (`time`), + CONSTRAINT `purchase_ibfk_1` FOREIGN KEY (`product`) REFERENCES `product` (`id`) +); + + + +CREATE TABLE `shop` ( + `id` int(11) NOT NULL, + `name` varchar(255) NOT NULL, + `account` int(11) NOT NULL, + `gamble_account` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `name` (`name`), + KEY `account` (`account`), + KEY `gamble_account` (`gamble_account`), + CONSTRAINT `shop_ibfk_2` FOREIGN KEY (`gamble_account`) REFERENCES `account` (`number`), + CONSTRAINT `shop_ibfk_1` FOREIGN KEY (`account`) REFERENCES `account` (`number`) +); + + + +CREATE TABLE `shop_admin` ( + `shop` int(11) NOT NULL, + `person` int(11) NOT NULL, + PRIMARY KEY (`shop`,`person`), + KEY `person` (`person`), + CONSTRAINT `shop_admin_ibfk_2` FOREIGN KEY (`person`) REFERENCES `person` (`id`), + CONSTRAINT `shop_admin_ibfk_1` FOREIGN KEY (`shop`) REFERENCES `shop` (`id`) +); + + + +CREATE TABLE `sold_by` ( + `product` int(11) NOT NULL DEFAULT '0', + `terminals` int(11) NOT NULL DEFAULT '0', + PRIMARY KEY (`product`,`terminals`), + KEY `terminals` (`terminals`), + CONSTRAINT `sold_by_ibfk_2` FOREIGN KEY (`product`) REFERENCES `product` (`id`), + CONSTRAINT `sold_by_ibfk_1` FOREIGN KEY (`terminals`) REFERENCES `terminal_group` (`id`) +); + + +CREATE TABLE `product` ( + `id` int(11) NOT NULL, + `shop` int(11) NOT NULL, + `name` varchar(255) NOT NULL, + `ean` int(11) DEFAULT NULL, + `unit` varchar(20) NOT NULL DEFAULT 'piece', + `category` int(11) NOT NULL, + `price` decimal(10,5) NOT NULL COMMENT 'Price per unit', + `int_amount` bit(1) NOT NULL DEFAULT '1' COMMENT 'Is purchase amount restricted to integer amounts (e.g. pieces)?', + `stock` decimal(10,5) NOT NULL DEFAULT '0.00000', + `thumbnail` varchar(255) DEFAULT NULL, + `picture` varchar(255) DEFAULT NULL, + `discount_amt` decimal(10,3) DEFAULT NULL COMMENT 'The amount of this product to buy in order to get mass discount', + `discount_price` decimal(10,5) DEFAULT NULL COMMENT 'The price per unit when buying more than discount_amt', + PRIMARY KEY (`id`), + KEY `ean` (`ean`), + KEY `category` (`category`), + KEY `shop` (`shop`), + KEY `name` (`name`), + CONSTRAINT `product_ibfk_2` FOREIGN KEY (`category`) REFERENCES `category` (`id`), + CONSTRAINT `product_ibfk_1` FOREIGN KEY (`shop`) REFERENCES `shop` (`id`) +); + + +CREATE TABLE `terminal` ( + `id` int(11) NOT NULL, + `name` varchar(255) NOT NULL, + `has_barcode` bit(1) NOT NULL DEFAULT '0', + `has_menu` bit(1) NOT NULL DEFAULT '0', + `has_cardreader` bit(1) NOT NULL DEFAULT '0', + `secret` varchar(255) NOT NULL, + PRIMARY KEY (`id`) +); + + + +CREATE TABLE `terminal_group` ( + `id` int(11) NOT NULL, + `name` varchar(255) NOT NULL, + `auto_insert` varchar(255) DEFAULT NULL COMMENT 'Comma-separated list of criteria. A pattern of 0,1,* will make all new terminals added without barcode, with menu and regardless of cardreader be added to this group by the software', + PRIMARY KEY (`id`) +); + + + +CREATE TABLE `terminal_group_member` ( + `terminal` int(11) NOT NULL DEFAULT '0', + `terminal_group` int(11) NOT NULL DEFAULT '0', + PRIMARY KEY (`terminal`,`terminal_group`), + KEY `terminal_group` (`terminal_group`), + CONSTRAINT `terminal_group_member_ibfk_2` FOREIGN KEY (`terminal_group`) REFERENCES `terminal_group` (`id`), + CONSTRAINT `terminal_group_member_ibfk_1` FOREIGN KEY (`terminal`) REFERENCES `terminal` (`id`) +); + + + +CREATE TABLE `transfer` ( + `id` int(11) NOT NULL, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `from_account` int(11) DEFAULT NULL COMMENT 'Account number, NULL for cash', + `to_account` int(11) DEFAULT NULL COMMENT 'Account number, NULL for cash', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00', + PRIMARY KEY (`id`), + KEY `from_account` (`from_account`), + KEY `to_account` (`to_account`), + KEY `time` (`time`), + CONSTRAINT `transfer_ibfk_2` FOREIGN KEY (`to_account`) REFERENCES `account` (`number`), + CONSTRAINT `transfer_ibfk_1` FOREIGN KEY (`from_account`) REFERENCES `account` (`number`) +); + diff --git a/setup/labipay.ps b/setup/labipay.ps new file mode 100644 index 0000000000000000000000000000000000000000..df670dd3efa61d93df4b6a5f0e9710a116f6451e GIT binary patch literal 20574 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 literal 0 HcmV?d00001 diff --git a/setup/labipay_a4.ps b/setup/labipay_a4.ps new file mode 100644 index 0000000000000000000000000000000000000000..db3d1785a29177e49ce45ff3b50d46d773515918 GIT binary patch literal 22567 zcmcIsTW{OQl71F{MK7?yIKbGv>kySABKqCSU#A`wy4@vCQxIrGMqHx9>l^qYLZRt6vHp zReAA-{bvzv9v+i_ut#2$NuDw9%5$zfcJXn`+5bcZV;*xCZyf(k;IjYy_zUwK*LjT| z-qF(+pLzP4{rA8AS*&=*-lnT4Dwgc`s3>{%#WE2zNupF3vD zfp`|(rJ^SmKR#jDBwO+)@gxdSQAE!yD)Ar}_jfPs4lS6S-1D@QbwR(`lHb1&6)&<` z>-s(~qBPwzepO&C@FeD$@~YwmepXRx^Cuo}tK?D6Hwx@1DA(x1z6Q!}|CYv4o*+kU?&Bn6vC)%c}i=7k`)gR4YNvP~k zRK$4}N2<(38!nBTbp9(_2HUA+=kG#r7U0=Y}a(u$mH|FCzq- z^g2#CiSRlqzF6$w{Kd3LuU>Z4y_T-zUM@vMz`-y)6oXcgR-^_LaUoy?V%x(NJY*72 zpkxaaQNSZ^+M6i;!k4Z3ys#J(7zP>Bx<)jxni>HkM7pMzo}d;+mDEM7i(XVIGGYee zkFKZL8zLxStsY7&LM_EBvK~oX?i!J1x}TaqF7-w${xw-v4;FKDIgU0>;WGr3-Fy-^ zmr?m3?_hvui@Bn9odEjM1V4>l@N6k935*7r3CsR9uYM)V;5iNxCW467mC{@Y^x1BH zkE$ed+0GI&VZY7de3@jc|Kl-Goq6F@!8dilI(Hq*`t8FXvIl(&6nkMv(g#)J1>}!3 zN?%*Lnd(0Xnf>s*0U}c?%X-B?K7(ySzp$Wq4uBUwL`4*%GsYElpuLI;Xb&~pVV&iF z1eEmuJ#5f@o>fvPdA5QU8HQ3f-_`cae)rq2STeRFFl8@0Ay_0jFHB2}dgz5oHLS<1 zIo4&4V3>MJ!H=K;i$$YA5{N|WMc6d^W3uMu<=>#08C1HS8+D@Y{Z>JhA(MM={+y@F zZ`?ZY?0@kTh?B%o@6r0sx6QFsNJ>;DoX7Q)qbC~6wg^1N2w4OAuN{tTlV;Ys@_2@prtO{S}`a{y%lX6r67H9CFU-Z zN7j8*SQ2_xNve1nLMhx3s)tIJ)pN>A=p3A&C3NKGlY}J!P)1XmD!6E|U>y?I+f-=d z!AEnrbmCEW-=ot#m4~{5(Mw9$?fV3Nk_LLAW(=@V{AOt?%zATXccfoC zL;>J%4dOtDnoVLfF#)r3n-LMc>LTC5jiwIG#>*sH!9PtwyIGF~c*yfFB>@i&4@_LK z7{NioEIZ4WTwd?!-a~mOOJ=`(x%$gCLcag1qb%R3CIpS@e`6%e7M6uPTDF_KVhQ`q zGAfnU#FiII5Fu9+`?Fa{%mP+PSUY&Gm@7%)L_O}ZRR!6eOjaURnoK5*24VgJQBesk zWzs`K)5*o(FJ3c>jX)7~9Y2<`c=4mE|JTC6#*y1UJi<(2S-qDPknA%I+5&eKIV6|*Y3u|;%Zto}wE#V8V7g>)kLXa3ZIa}`5abg0uIG+Rnt64ELz4Q;EV zeE}B&s_is-=7rGFlK*KIU$tA)ewUjkwO2tWe>CaK=Bnm+O= zg*_q_0jf(5sfGZ0khFf@A{vEnOTn`gy7hCKBPKPf>JIg-6q7V6Wfv<{B2bz%LqeeB zkab(>Dadd^`#E*+U}T&=@M^oU+<>0%IWky20eqH-YMPJ)=tkjO{#7kDLKYfc*Gg0g zB}uQ4ZGb5*x5yoBmiH8XFxYMQ`fGR_(%d0&CnASU!QuXwEU%EjV$}nj?ds%MeH$Tl9sJ4+79~-%gRjW>;isQ|U=dvOAFi)|U}bdA8Pb8sxc$zN zMS>?Kb2adW(kNSPVcb%2qZu30Xxw<&x%ChpOUZbVr&UvT@zYPQVURL*aU*|aY>il` z-R%1MKr?~8bt}_y0X=fA~uo0l(GV$(BUMi--9(s zjXJNH+og_7H=-5%6+vH%OmreCwVW$Q{0ErSR}G*#SBsg4|AbE|VO6tqK;DZ8?=+|2 ze`mN2Z0dS?*xIH59OqL|6VBYg*M^2&?rUnX+WJsJ5P4IXC$Z#gWqM4qSTXBoatdLD zf6kxYV9xY!I-g>|KsERSp;A^MCeAbDfsv~%qNK8jW>;o_$wFAt?5pQP5bVvFHpFIz z1F9>OSH%mMV~)N#3mp2_aQYQP_6Vd^s%Nn*O#VgOf&4;^sg|;S@ZAzJAi_}=%t=R_e2;M*^Z6e)iDI1CiNy_&kLIw=q91s}U3jZ2#^js{Vd$mxE zU}3;66gOWZZ&kZ@XU=ju3u;yqZ&6gtJd1jFdKI#1IJ?rlv#HN6E?Kr+-|?b70aGfv z3v!r0a)iKXMj#GdvivLj#PJ63{APe)E~bD4>3ID+)29C^HjOUXT?8W#cZR4ViB~Mf z2GJ4vge+MQdU}mf3PWEnvFr7U)OKub8iRy$q;cjz7(+j7l*wDL3*uvY9Z$y#Vk@vx z1s{T~D2|{=SgX)=8xeukf`}v?!cWp5bR8^$rt7ItpS=)sa{n^}0ATe%`UB#=HrG$? zd}`?aA=NbWM$PQ<+EAd*iuXyW9s9;UdDE7C0#1kX8FO7!&qDm746K9COdB%RLAl-2 zv+G|i&?RP>g*?SAP z4(Wbhn};x7P#>7UH8wD+DVXr(0!09ht7wfA6SsRY*ifk&$qe1nTr>q;+EmWr?Hdls9Dx(H5fIq(L;C0?--3=-*lcYx#}qnwJpgB9|H0gFN?k{I zNcTm)-VXui3^biL6G3KxCX$aoN@xVEGT@8W6#uswwhzj*+$lxwpiF<}d*F=1SEL^l zvUs?hLYaLZzmA8*1Yiw%t{pR7ZhpyeYwj!E>JTyrbO>1@<8~3 zJe!El5w;m`V1iy5iqu*n-&vd?pdgk32%t33pG&XTUqCh81rxu3T@4CU4i-N1fhAxI zegPbFoFZyRa~(p4g966Cf;U0#WsM3%VlZn^VUBsB30f`&;R24CEVuD7DOL|LstZ?4 zBPEJ~Q%94&50PjWsqyHrFbWap=#>{xlG0zm*Ho-Pl89X>ezwK4v*O3#0_I6#rzH^F zrQ!&<;E@1N4F_UGTo8Q+hR+a)#8W>4?JMB`=STGJ8@|G*s3UrJ4ZjvF2kP)BYp?0a zqyy?3k?}=Rc#AEXv3dsp)B=eR4Ww%v9BTw*kLnR*S`KF|x+&+=*#o_*yGMM731B1k zcAqDB`WT+!_9G#NF>ul*{xTMauz}Z1L99XbfUS!xO7OOZx%+bR4BUmGxya>V1{<8Z z#g6t^1I!Hdb>RVHgk@5S{WvVfSmNqV4Lx>YH_T!7h}B+0-?m{Lu72X(N%9#l8tf_zqcF|9k@a!2}MC zlzDbUC;<$eVPjZZu@HO1wR7uYJEc>kwqtt?V~}u;zYt<|+1zx*gBjcf!O6DHb_Dnc zZ-M5B)URSQF_wxWI17OJ8NALK)JF6bsB22tX(wt_V@{n1hPj$abBnjv=tY$_+YM-u zt8#g*Ql)2P72t~-f=>W9^Ju#Vbh-39V>8rs0)!#J;)=HfXU>8mf_lNd*6{|YJ`IRX zVvsRNpN7XG=a1iq74i>nqvaZJ6b?_w;-wELlHvePHeypmO%=)Mc8MAeV#5(Cckb}# zK+2qX3)5Lrn>><*ZQ(v0Q!qhzVT!+CDV0V*6m&H6n(gCJ0|fLPhE3lpZLkMv_o#eN z20NS^qMLBL4W$q12olV@kAxz83EF;b@`OGz3D`pL5wc_R7B^|TK*Iro1LOhW7%(5s z19EM?*W5fPIDPj9kr;V1UT{?hl5lzr-3VNAK;Q*k4Ya$%apJtO`xvpo+c7u=pii5b z%W-JS#shcj91ay+_;N~weIRkCSUV=)-WWduBhDw_;9?I6m!Vs(V^~;---Tx5SSUeL zMCMDu1aTFm5n#e2(PT3t*cs9Fke$OZ)A$Ar6_J$8iM4rSN}9YL#CswibErocbsI=d z0D4__e|X#E4Ehbn4=6^VW3s3d(v z#L-A{4w@{0g*2)Gh_fknRR}N#ZfFe0!*fRNqoHK=+3~N3lY9oBJ#2ghKn)MpDM0$0 zl>5Z^*Yk1W2))VJR>1=*&G^^zovRsYWQ;t>K^xBR5eV4BcD$2!dw0xUnvW@i$r>L~ zYSQxoB7$}aEeEmT2pe=h0SDD<01o;gCCg_9P=(kI$0`yV+c?|tw~n0RvWqLtof^>M!_`4TV6pfP`G9jr@AF(M zz!ABC02{C#d`~56RPjA4PreNkbaN<%8f^T_k#~Q&$(m*XPkV)*3(LCt*JHB1E9OW$;F%{2vK>=9^ zOr_*8-nwj0#ISo>l5bb|n!)CF&FQ6}V^9L>(D-5jN{#n5MS#;TeFtBRhCGjebI>8c zrhnTPbEnv5-OJVX8k_+Zm~rEp5tGMp&E#BK&Y8&Y>&dj~KTX{PyG;Mgb%j>zF{Bu_ zN7^H;m)GqF+H%`rZJ*M^;cUIRYJgoZ|6(olvo;NZZ3|ssWtyPLUc9V#Exa^@gf-@oKffrPod5q{zsNP#AVgvy7 zw-@^=J;2th{`*H_qAdmyF!AQWs=BS4%&UwQM9>UEJW zqGOLIA`VVYQkn>n4|X6F==gXqt^pl?4onh=hE0q?>NXsA&K>=)&}Ve$4}kCHLSSFh z!@g&Lca2<}Hw|#^lwwQ`{d6YPrHv=#O)1C>5q@JwpuT?)C;B3QL&?aTQjl4n3lFg= z)vQzKhKNGN5x0?7>8zoId_UqNzHmTa_z)kNkROzA)dv*h iHzf48nec_(x+%4zuj}HeZ+udLKAG4&{q~P{*8c;#Wl%T( literal 0 HcmV?d00001 diff --git a/style.css b/style.css new file mode 100644 index 0000000..2f12bd3 --- /dev/null +++ b/style.css @@ -0,0 +1,120 @@ + +body { + background: white; + font-family: verdana, sans-serif; + color: black; + margin-left: 50px; + margin-right: 50px; + margin-top: 15px; +} + + +.header { + width: 100%; + background: #55cc55; + color: white; + padding: 5px; + padding-right: 1em; + font-weight: bold; + text-align: right; + margin-right: 1em; + margin-bottom: 1.5em; +} + +a { + text-decoration: none; + border: none; + border-bottom: 1px #44bb44 dashed; + color: #44bb44; +} + +a:hover { + border-bottom-style: solid; +} + +h1 { + color: #44bb44; + font-weight: bold; + font-size: 1.7em; +} + +h3 { + color: #44bb44; + font-weight: bold; + font-size: 1em; + margin: 0px; + padding: 0px; + margin-top: 3em; + margin-bottom: 0.4em; +} + +.error { + border: 2px red solid; + background: #ffff55; + color: black; + padding: 4px; + margin-top: 1em; + margin-bottom: 1em; + width: auto; + display: inline-block; +} + +.good { + border: 1px #55cc55 solid; + background: white; + color: #44bb44; + padding: 0.8em; + margin-top: 1em; + margin-bottom: 1em; + display: inline-block; + width: auto; +} + +.rightbox { + border: 1px #55cc55 solid; + padding: 0.8em; + float: right; + margin-left: 5em; +} + +hr.rightboxspacer { + height: 1px; + color: #55cc55; + width: 100%; + border: none; + border-top: 1px #55cc55 solid; +} + + +input { + background: white; + border: 1px #55cc55 solid; + color: black; +} + +select { + background: white; + border: 1px #55cc55 solid; + color: black; +} + + +input[type=submit] { + background: #55cc55; + color: white; + cursor: pointer; +} + +select[disabled] { + background: #dddddd; +} + +input[disabled] { + background: #dddddd; + pointer: crosshair; +} + + +.negative { + color: red; +} diff --git a/templates/default.html b/templates/default.html new file mode 100644 index 0000000..9dad92c --- /dev/null +++ b/templates/default.html @@ -0,0 +1,19 @@ + + + + + + Labipay: <!--TITLE--> + + + + +

Labipay

+ +

+ + + +