- added LDAP ACL stuff to the readme

- reworked Admin >> Addressbook >> Site config
- fixed for LDAP and SQL (eg. LDAP error are now forwarded to the UI)
This commit is contained in:
Ralf Becker 2006-06-13 21:53:00 +00:00
parent ebdec8bcbb
commit 5e0d628d93
9 changed files with 249 additions and 160 deletions

View File

@ -10,9 +10,100 @@ like eg. the home-address you need to use some other supported schema:
- mozillaOrgPerson older mozilla schema (depricated, but mostly compatible to mozillaAbPersonAlpha)
Please note:
You can install the evolutionPerson schema together with ONE
You can or should install the evolutionPerson schema together with ONE
of the mozilla schemas. You can NOT install both mozilla schema!
If the addressbook detects the schemas, it fills the extra fields of each schema.
If the addressbook detects a schema, it fills the extra fields of that schema.
Ralf
LDAP layout used for the eGroupWare addressbook
-----------------------------------------------
dc=domain,dc=com base DN of your LDAP server
|
+-o=default base DN for the addressbook of eGroupWare domain / DB instance "default"
| | (specified in Admin >> Addressbook >> Site config)
| |
| +-ou=accounts base DN for accounts (specified in Setup >> Configuration)
| | +-uid=ralf entry for user ralf
| | +-uid=lars entry for user lars
| | +-uid=... other users
| |
| +-ou=groups base DN for groups (specified in Setup >> Configuration)
| | +-cn=Default entry for the group Default
| | +-cn=... other groups
| |
| +ou=contacts
| |
| +-ou=shared shared addressbooks of the groups
| | +-cn=default addressbook of group Default
| | +-cn=...
| |
| +-ou=personal personal addressbooks of the users
| +-cn=ralf addressbook of user ralf
| +-cn=lars addressbook of user lars
| +-cn=...
|
+-o=other other eGroupWare domain / DB instance
+-...
The contact base DN must include the accounts and groups base DN, otherwise they will not be
searched AND the ACL given below does NOT work!
The following ACL in slapd conf allow:
-------------------------------------
- everyone to read the account addressbook
- the user to edit his account (incl. password)
- the egwadmin user for each domain to edit all accounts (eGW uses it when admins edit accounts)
- only the user to read, edit or delete in his personal addressbook
- group-members to read, edit or delete in their group addressbook
Add or include the rows after the line behind the exiting ACL rules in your slapd.conf
Please note:
-----------
- You need to change all dc=domain,dc=com with the base DN your LDAP uses!!!
- If you want to use the old mozillaOrgPerson schema, you need to change it here too!
---------------------------------------------------------------------------------------------------
# Access to users personal addressbooks
# allow read of addressbook by owner and egwadmin account
access to dn.regex="^cn=([^,]+),ou=personal,ou=contacts,o=([^,]+),dc=domain,dc=com$"
attrs=entry
by dn.regex="uid=$1,ou=accounts,o=$2,dc=domain,dc=com" read
by dn.regex="cn=egwadmin,o=$2,dc=domain,dc=com" write
by users none
# allow user to create entries in own addressbook; no-one else can access it
# needs write access to the entries ENTRY attribute ...
access to dn.regex="cn=([^,]+),ou=personal,ou=contacts,o=([^,]+),dc=domain,dc=com$"
attrs=children
by dn.regex="uid=$1,ou=accounts,o=$2,dc=domain,dc=com" write
by users none
# ... and the entries CHILDREN
access to dn.regex="cn=([^,]+),ou=personal,ou=contacts,o=([^,]+),dc=domain,dc=com$"
attrs=entry,@inetOrgPerson,@mozillaAbPersonAlpha,@evolutionPerson
by dn.regex="uid=$1,ou=accounts,o=$2,dc=domain,dc=com" write
by users none
# Access to groups addressbooks
# allow read of addressbook by members and egwadmin account
access to dn.regex="^cn=([^,]+),ou=shared,ou=contacts,o=([^,]+),dc=domain,dc=com$"
attrs=entry
by group.expand="cn=$1,ou=groups,o=$2,dc=domain,dc=com" read
by dn.regex="cn=egwadmin,o=$2,dc=domain,dc=com" write
by users none
# allow members to create entries in there group addressbooks; no-one else can access it
# needs write access to the entries ENTRY attribute ...
access to dn.regex="cn=([^,]+),ou=shared,ou=contacts,o=([^,]+),dc=domain,dc=com$"
attrs=children
by group.expand="cn=$1,ou=groups,o=$2,dc=domain,dc=com" write
by users none
# ... and the entries CHILDREN
access to dn.regex="cn=([^,]+),ou=shared,ou=contacts,o=([^,]+),dc=domain,dc=com$"
attrs=entry,@inetOrgPerson,@mozillaAbPersonAlpha,@evolutionPerson
by group.expand="cn=$1,ou=groups,o=$2,dc=domain,dc=com" write
by users none

View File

@ -91,6 +91,13 @@ class bocontacts extends socontacts
var $business_contact_fields = array();
var $home_contact_fields = array();
/**
* Number and message of last error or false if no error, atm. only used for saving
*
* @var string/boolean
*/
var $error;
function bocontacts($contact_app='addressbook')
{
$this->socontacts($contact_app);
@ -403,6 +410,7 @@ class bocontacts extends socontacts
}
if($contact['id'] && !$this->check_perms(EGW_ACL_EDIT,$contact))
{
$this->error = 'access denied';
return false;
}
// convert categories
@ -414,11 +422,11 @@ class bocontacts extends socontacts
$contact['n_fn'] = $this->fullname($contact);
$contact['n_fileas'] = $this->fileas($contact);
if(!($error_nr = parent::save($contact)))
if(!($this->error = parent::save($contact)))
{
$GLOBALS['egw']->contenthistory->updateTimeStamp('contacts', $contact['id'],$isUpdate ? 'modify' : 'add', time());
}
return !$error_nr;
return !$this->error;
}
/**

View File

@ -1,16 +1,14 @@
<?php
/**************************************************************************\
* eGroupWare - Addressbook Admin-, Preferences- and SideboxMenu-Hooks *
* http://www.eGroupWare.org *
* Written and (c) 2006 by Ralf Becker <RalfBecker@outdoor-training.de> *
* ------------------------------------------------------------------------ *
* This program 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 2 of the License, or (at your *
* option) any later version. *
\**************************************************************************/
/* $Id$ */
/**
* Addressbook - admin, preferences and sidebox-menus
*
* @link http://www.egroupware.org
* @package addressbook
* @author Ralf Becker <RalfBecker@outdoor-training.de>
* @copyright (c) 2006 by Ralf Becker <RalfBecker@outdoor-training.de>
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @version $Id$
*/
/**
* Class containing admin, preferences and sidebox-menus (used as hooks)
@ -22,14 +20,14 @@
*/
class contacts_admin_prefs
{
var $contacts_repository = 'sql';
var $contact_repository = 'sql';
/**
* constructor
*/
function contacts_admin_prefs()
{
if($GLOBALS['egw_info']['server']['contact_repository'] == 'ldap') $this->contacts_repository = 'ldap';
if($GLOBALS['egw_info']['server']['contact_repository'] == 'ldap') $this->contact_repository = 'ldap';
}
/**
@ -69,7 +67,7 @@ class contacts_admin_prefs
'Grant Access' => $GLOBALS['egw']->link('/index.php','menuaction=preferences.uiaclprefs.index&acl_app='.$appname),
'Edit Categories' => $GLOBALS['egw']->link('/index.php','menuaction=preferences.uicategories.index&cats_app=' . $appname . '&cats_level=True&global_cats=True')
);
if ($this->contacts_repository == 'ldap' || $GLOBALS['egw_info']['server']['deny_user_grants_access'])
if ($this->contact_repository == 'ldap' || $GLOBALS['egw_info']['server']['deny_user_grants_access'])
{
unset($file['Grant Access']);
}
@ -176,7 +174,7 @@ class contacts_admin_prefs
'admin' => false,
);
if ($this->contacts_repository == 'sql')
if ($this->contact_repository == 'sql')
{
$GLOBALS['settings']['private_addressbook'] = array(
'type' => 'check',

View File

@ -294,7 +294,7 @@ class so_ldap
if((int)$data['owner'])
{
// group address book
if(!$cn = strtolower($GLOBALS['egw']->accounts->id2name((int)$data['owner'])))
if(!($cn = strtolower($GLOBALS['egw']->accounts->id2name((int)$data['owner']))))
{
return true;
}
@ -313,49 +313,27 @@ class so_ldap
return true; // only admin is allowd to write accounts!
}
// check if $baseDN exists. If not create new one
if(!($result = ldap_read($this->ds, $baseDN, 'objectclass=*')))
// check if $baseDN exists. If not create it
if (($err = $this->_check_create_dn($baseDN)))
{
if(ldap_errno($this->ds) == 32 && $cn)
{
// create a admin connection to add the needed DN
$adminLDAP =& new ldap;
$adminDS = $adminLDAP->ldapConnect();
// emtry does not exist, lets try to create it
$baseDNData['objectClass'] = 'organizationalRole';
$baseDNData['cn'] = $cn;
if(!ldap_add($adminDS, $baseDN, $baseDNData))
{
$adminLDAP->ldapDisconnect();
return true;
}
$adminLDAP->ldapDisconnect();
}
else
{
return true;
}
return $err;
}
// check the existing objectclasses of an entry, none = array() for new ones
$oldObjectclasses = array();
$attributes = array('dn','cn','objectClass','uid');
if(!empty($this->data[$this->contacts_id]))
$contactUID = $this->data[$this->contacts_id];
if(!empty($contactUID) &&
($result = ldap_search($this->ds, $GLOBALS['egw_info']['server']['ldap_contact_context'],
'(|(entryUUID='.ldap::quote($contactUID).')(uid='.ldap::quote($contactUID).'))', $attributes)) &&
($oldContactInfo = ldap_get_entries($this->ds, $result)) && $oldContactInfo['count'])
{
$contactUID = $this->data[$this->contacts_id];
$result = ldap_search($this->ds, $GLOBALS['egw_info']['server']['ldap_contact_context'],
'(|(entryUUID='.ldap::quote($contactUID).')(uid='.ldap::quote($contactUID).'))', $attributes);
$oldContactInfo = ldap_get_entries($this->ds, $result);
foreach($oldContactInfo[0]['objectclass'] as $objectclass)
{
$oldObjectclasses[] = strtolower($objectclass);
}
$isUpdate = true;
}
if(!$contactUID)
{
$contactUID = md5($GLOBALS['egw']->common->randomstring(15));
@ -370,7 +348,7 @@ class so_ldap
if(!in_array($objectclass, $oldObjectclasses))
{
$newObjectClasses['objectClass'][] = $objectclass;
$ldapContact['objectClass'][] = $objectclass;
}
if (isset($this->required_subs[$objectclass]))
{
@ -378,7 +356,7 @@ class so_ldap
{
if(!in_array($sub, $oldObjectclasses))
{
$newObjectClasses['objectClass'][] = $sub;
$ldapContact['objectClass'][] = $sub;
}
}
}
@ -410,20 +388,21 @@ class so_ldap
$needRecreation = false;
// add missing objectclasses
if(count($newObjectClasses) > 0)
if($ldapContact['objectClass'] && array_diff($ldapContact['objectClass'],$oldObjectclasses))
{
$result = @ldap_mod_add($this->ds, $dn, $newObjectClasses);
if(!$result)
if (!@ldap_mod_add($this->ds, $dn, array('objectClass' => $ldapContact['objectClass'])))
{
if(ldap_errno($this->ds) == 69)
{
// need to modify structural objectclass
$needRecreation = true;
}
else
{
//echo "<p>ldap_mod_add($this->ds,'$dn',array(objectClass =>".print_r($ldapContact['objectClass'],true)."))</p>\n";
error_log('class.so_ldap.inc.php ('. __LINE__ .') update of '. $dn .' failed errorcode: '. ldap_errno($this->ds) .' ('. ldap_error($this->ds) .')');
return ldap_errno($this->ds).': '.ldap_error($this->ds);
return $this->_error(__LINE__);
}
}
}
@ -445,45 +424,46 @@ class so_ldap
}
$newContact['uid'] = $contactUID;
if(is_array($newObjectClasses['objectClass']) && count($newObjectClasses['objectClass']) > 0)
if(is_array($ldapContact['objectClass']) && count($ldapContact['objectClass']) > 0)
{
$newContact['objectclass'] = array_merge($newContact['objectclass'], $newObjectClasses['objectClass']);
$newContact['objectclass'] = array_merge($newContact['objectclass'], $ldapContact['objectClass']);
}
if(ldap_delete($this->ds, $dn))
{
if(!ldap_add($this->ds, $newDN, $newContact))
if(!@ldap_add($this->ds, $newDN, $newContact))
{
//echo "<p>recreate: ldap_add($this->ds,'$newDN',".print_r($newContact,true).")</p>\n";
//print 'class.so_ldap.inc.php ('. __LINE__ .') update of '. $dn .' failed errorcode: '. ldap_errno($this->ds) .' ('. ldap_error($this->ds) .')';_debug_array($newContact);exit;
return ldap_errno($this->ds).': '.ldap_error($this->ds);
return $this->_error(__LINE__);
}
}
}
else
{
error_log('class.so_ldap.inc.php ('. __LINE__ .') delete of old '. $dn .' failed errorcode: '. ldap_errno($this->ds) .' ('. ldap_error($this->ds) .')');
return ldap_errno($this->ds).': '.ldap_error($this->ds);
return $this->_error(__LINE__);
}
$dn = $newDN;
}
unset($ldapContact['objectClass']);
$result = ldap_modify($this->ds, $dn, $ldapContact);
if (!$result)
if (!@ldap_modify($this->ds, $dn, $ldapContact))
{
//echo "<p>ldap_modify($this->ds,'$dn',".print_r($ldapContact,true).")</p>\n";
error_log('class.so_ldap.inc.php ('. __LINE__ .') update of '. $dn .' failed errorcode: '. ldap_errno($this->ds) .' ('. ldap_error($this->ds) .')');
return ldap_errno($this->ds).': '.ldap_error($this->ds);
return $this->_error(__LINE__);
}
}
else
}
else
{
$dn = 'uid='. ldap::quote($ldapContact['uid']) .','. $baseDN;
$result = ldap_add($this->ds, $dn, $ldapContact);
if (!$result)
if (!@ldap_add($this->ds, $dn, $ldapContact))
{
//echo "<p>ldap_add($this->ds,'$dn',".print_r($ldapContact,true).")</p>\n";
error_log('class.so_ldap.inc.php ('. __LINE__ .') add of '. $dn .' failed errorcode: '. ldap_errno($this->ds) .' ('. ldap_error($this->ds) .')');
return ldap_errno($this->ds).': '.ldap_error($this->ds);
return $this->_error(__LINE__);
}
}
return 0; // Ok, no error
@ -516,7 +496,7 @@ class so_ldap
"(|(entryUUID=$entry)(uid=$entry))", $attributes))
{
$contactInfo = ldap_get_entries($this->ds, $result);
if(ldap_delete($this->ds, $contactInfo[0]['dn']))
if(@ldap_delete($this->ds, $contactInfo[0]['dn']))
{
$ret++;
}
@ -861,6 +841,68 @@ class so_ldap
return mktime(substr($date,8,2),substr($date,10,2),substr($date,12,2),
substr($date,4,2),substr($date,6,2),substr($date,0,4));
}
/**
* check if $baseDN exists. If not create it
*
* @param string $baseDN cn=xxx,ou=yyy,ou=contacts,$GLOBALS['egw_info']['server']['ldap_contact_context']
* @return boolean/string fase on success or string with error-message
*/
function _check_create_dn($baseDN)
{
// check if $baseDN exists. If not create new one
if(@ldap_read($this->ds, $baseDN, 'objectclass=*'))
{
return false;
}
if(ldap_errno($this->ds) != 32 || substr($baseDN,0,3) != 'cn=')
{
return $this->_error(__LINE__); // baseDN does NOT exist and we cant/wont create it
}
// create a admin connection to add the needed DN
$adminLDAP =& new ldap;
$adminDS = $adminLDAP->ldapConnect();
list(,$ou) = explode(',',$baseDN);
foreach(array(
'ou=contacts,'.$GLOBALS['egw_info']['server']['ldap_contact_context'],
$ou.',ou=contacts,'.$GLOBALS['egw_info']['server']['ldap_contact_context'],
$baseDN,
) as $dn)
{
if (!@ldap_read($this->ds, $dn, 'objectclass=*') && ldap_errno($this->ds) == 32)
{
// entry does not exist, lets try to create it
list($top) = explode(',',$dn);
list($var,$val) = explode('=',$top);
$data = array(
'objectClass' => $var == 'cn' ? 'organizationalRole' : 'organizationalUnit',
$var => $val,
);
if(!@ldap_add($adminDS, $dn, $data))
{
//echo "<p>ldap_add($adminDS,'$dn',".print_r($data,true).")</p>\n";
$err = $this->_error(__LINE__,$adminDS);
$adminLDAP->ldapDisconnect();
return $err;
}
}
}
$adminLDAP->ldapDisconnect();
return false;
}
/**
* error message for failed ldap operation
*
* @param int $line
* @return string
*/
function _error($line,$ds=null)
{
return ldap_error($ds ? $ds : $this->ds).': so_ldap: '.$line;
}
/**
* Special handling for mapping of eGW contact-data to the evolutionPerson objectclass

View File

@ -643,7 +643,6 @@ class socontacts
$rows[$n] = $this->db2data($row);
}
}
// ToDo: read custom-fields, if displayed in the index page
return $rows;
}

View File

@ -295,6 +295,10 @@ class socontacts_sql extends so_sql
$join .= $this->extra_join;
if (is_string($only_keys)) $only_keys = 'DISTINCT '.str_replace(array('contact_id','contact_owner'),
array($this->table_name.'.contact_id',$this->table_name.'.contact_owner'),$only_keys);
// only return the egw_addressbook columns, to not generate dublicates by the left join
// and to not return the NULL for contact_{id|owner} of not found custom fields!
if (is_bool($only_keys)) $only_keys = 'DISTINCT '.$this->table_name.'.*';
if (isset($filter['owner']))
{

View File

@ -57,8 +57,8 @@ class uicontacts extends bocontacts
$this->$my = &$GLOBALS['egw']->$class;
}
$this->prefs =& $GLOBALS['egw_info']['user']['preferences']['addressbook'];
$this->private_addressbook = $this->contacts_repository == 'sql' && $this->prefs['private_addressbook'];
$this->private_addressbook = $this->contact_repository == 'sql' && $this->prefs['private_addressbook'];
$this->org_views = array(
'org_name' => lang('Organisations'),
'org_name,adr_one_locality' => lang('Organisations by location'),
@ -187,7 +187,7 @@ class uicontacts extends bocontacts
$sel_options['org_view'][(string) $content['nm']['org_view']] = $org_name;
}
$content['nm']['org_view_label'] = $sel_options['org_view'][(string) $content['nm']['org_view']];
$this->tmpl->read('addressbook.index');
return $this->tmpl->exec('addressbook.uicontacts.index',$content,$sel_options,$readonlys,$preserv);
}
@ -339,6 +339,20 @@ class uicontacts extends bocontacts
//echo "<p>uicontacts::get_rows(".print_r($query,true).")</p>\n";
if (!$id_only)
{
// check if accounts are stored in ldap, which does NOT yet support the org-views
if ($this->so_accounts && $query['filter'] === '0' && $query['org_view'])
{
$old_state = $GLOBALS['egw']->session->appsession('index','addressbook');
if ($old_state['filter'] === '0') // user changed to org_view
{
$query['filter'] = ''; // --> change filter to all contacts
}
else // user changed to accounts
{
$query['org_view'] = ''; // --> change to regular contacts view
}
unset($old_state);
}
$GLOBALS['egw']->session->appsession('index','addressbook',$query);
// save the state of the index in the user prefs
$state = serialize(array(
@ -352,7 +366,8 @@ class uicontacts extends bocontacts
if ($state != $this->prefs['index_state'])
{
$GLOBALS['egw']->preferences->add('addressbook','index_state',$state);
$GLOBALS['egw']->preferences->save_repository();
// save prefs, but do NOT invalid the cache (unnecessary)
$GLOBALS['egw']->preferences->save_repository(false,'user',false);
}
}
if (isset($query['col_filter']['cat_id'])) unset($query['col_filter']['cat_id']);
@ -426,7 +441,7 @@ class uicontacts extends bocontacts
}
$rows = parent::search($query['search'],$id_only ? array('id','org_name','n_family','n_given','n_fileas') : false,
$order,'','%',false,'OR',array((int)$query['start'],(int) $query['num_rows']),$query['col_filter']);
if (!$id_only && $this->prefs['custom_colum'] != 'never' && $rows) // do we need the custom fields
{
foreach((array) $rows as $n => $val)
@ -718,7 +733,8 @@ class uicontacts extends bocontacts
}
else
{
$content['msg'] = lang('Error saving the contact !!!');
$content['msg'] = lang('Error saving the contact !!!').
($this->error ? ' '.$this->error : '');
$button = 'apply'; // to not leave the dialog
}
// writing links for new entry, existing ones are handled by the widget itself

View File

@ -1,54 +0,0 @@
<?php
/**************************************************************************\
* eGroupWare *
* http://www.egroupware.org *
* Written by Miles Lott <milos@groupwhere.org> *
* -------------------------------------------- *
* This program 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 2 of the License, or (at your *
* option) any later version. *
\**************************************************************************/
/* $Id$ */
/*
Set a global flag to indicate this file was found by admin/config.php.
config.php will unset it after parsing the form values.
*/
$GLOBALS['egw_info']['server']['found_validation_hook'] = True;
/* Check a specific setting. Name must match the setting. */
function ldap_contact_context($value='')
{
if($value == $GLOBALS['egw_info']['server']['ldap_context'])
{
$GLOBALS['config_error'] = 'Contact context for ldap must be different from the context used for accounts';
}
elseif($value == $GLOBALS['egw_info']['server']['ldap_group_context'])
{
$GLOBALS['config_error'] = 'Contact context for ldap must be different from the context used for groups';
}
else
{
$GLOBALS['config_error'] = '';
}
}
/* Check all settings to validate input. Name must be 'final_validation' */
function final_validation($value='')
{
if($value['contact_repository'] == 'ldap' && !$value['ldap_contact_dn'])
{
$GLOBALS['config_error'] = 'Contact dn must be set';
}
elseif($value['contact_repository'] == 'ldap' && !$value['ldap_contact_context'])
{
$GLOBALS['config_error'] = 'Contact context must be set';
}
else
{
$GLOBALS['config_error'] = '';
}
}
?>

View File

@ -5,22 +5,14 @@
<tr class="th">
<td colspan="2"><font color="{th_text}">&nbsp;<b>{title}</b></font></td>
</tr>
<tr bgcolor="{th_err}">
<td colspan="2">&nbsp;<b>{error}</b></font></td>
<tr>
<td colspan="2">&nbsp;<i><font color="red">{error}</i></font></td>
</tr>
<!-- END header -->
<!-- BEGIN body -->
<tr class="th">
<td colspan="2">&nbsp;<b>{lang_Addressbook}/{lang_Contact_Settings}</b></font></td>
<td colspan="2">&nbsp;<b>{lang_Addressbook}/{lang_Contact_Settings}</b></td>
</tr>
<!--
<tr class="row_on">
<td>{lang_Contact_application}:</td>
<td><input name="newsettings[contact_application]" value="{value_contact_application}"></td>
</tr>
<tr class="row_off">
<td align="center" colspan="2">{lang_WARNING!!_LDAP_is_valid_only_if_you_are_NOT_using_contacts_for_accounts_storage!}</td>
</tr> -->
<tr class="row_off">
<td>{lang_Select_where_you_want_to_store_/_retrieve_contacts}.</td>
<td>
@ -38,22 +30,15 @@
<td>{lang_LDAP_context_for_contacts}:</td>
<td><input name="newsettings[ldap_contact_context]" value="{value_ldap_contact_context}" size="40"></td>
</tr>
<tr class="row_on">
<td>{lang_LDAP_root_dn_for_contacts}:</td>
<td><input name="newsettings[ldap_contact_dn]" value="{value_ldap_contact_dn}" size="40"></td>
</tr>
<tr class="row_off">
<td>{lang_LDAP_root_pw_for_contacts}:</td>
<td><input name="newsettings[ldap_contact_pw]" type="password" value=""></td>
</tr>
<tr class="th">
<td colspan="2">
{lang_Additional_information_about_using_LDAP_as_contact_repository}:
<a href="addressbook/doc/README" target="_blank">README</a>
</td>
</tr>
<!-- END body -->
<!-- BEGIN footer -->
<tr class="th">
<td colspan="2">
&nbsp;
</td>
</tr>
<tr>
<tr valign="bottom" style="height: 30px;">
<td colspan="2" align="center">
<input type="submit" name="submit" value="{lang_submit}">
<input type="submit" name="cancel" value="{lang_cancel}">