merge master

This commit is contained in:
Hadi Nategh 2020-03-05 14:45:25 +01:00
commit 0cc7ce12d0
79 changed files with 3527 additions and 411 deletions

View File

@ -6,32 +6,22 @@ php:
# none of our dependencies allow 8.0
# - master
matrix:
os: linux
jobs:
fast_finish: true
# allow_failures:
# - php: master
services:
- memcached
- mysql #we use mariadb instead installed via addons below
- mysql
# - postgres
#addons:
# mariadb: '10.0'
sudo: required
before_script:
- sudo apt-get update -qq
- sudo apt-get install -y libpcre3-dev
- sudo apt-get install -y libpcre3-dev apache2 libapache2-mod-fastcgi
- case $(phpenv version-name) in
"5.6")
yes "" | pecl install memcache;
yes "" | pecl install apcu-4.0.11;
yes "" | pecl install igbinary;
echo "extension=memcached.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini;
phpenv config-rm xdebug.ini;
;;
"7"|"7.0"|"7.1"|"7.2")
yes "" | pecl install apcu;
echo "extension=memcached.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini;
@ -41,17 +31,19 @@ before_script:
echo "extension=memcached.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini;
;;
esac
- case $(phpenv version-name) in
"5.6")
composer require 'phpunit/phpunit:~5.7';
;;
"7"|"7.0")
composer require 'phpunit/phpunit:~6';
;;
*)
composer require --ignore-platform-reqs 'phpunit/phpunit:~7';
;;
esac
# enable apache with php-fpm see https://docs.travis-ci.com/user/languages/php/#apache--php
- sudo cp ~/.phpenv/versions/$(phpenv version-name)/etc/php-fpm.conf.default ~/.phpenv/versions/$(phpenv version-name)/etc/php-fpm.conf
- sudo a2enmod rewrite actions fastcgi alias
- echo "cgi.fix_pathinfo = 1" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- sudo sed -i -e "s,www-data,travis,g" /etc/apache2/envvars
- sudo chown -R travis:travis /var/lib/apache2/fastcgi
- ~/.phpenv/versions/$(phpenv version-name)/sbin/php-fpm
# configure apache virtual hosts
- sudo cp -f doc/travis-ci-apache.conf /etc/apache2/sites-available/000-default.conf
- sudo sed -e "s?%TRAVIS_BUILD_DIR%?$(pwd)?g" --in-place /etc/apache2/sites-available/000-default.conf
# remove .htaccess as it is read by Apache, but content is for mod_php
- rm -f .htaccess
- sudo service apache2 restart
- php -m
- php -i
- php install-cli.php --ignore-platform-reqs
@ -64,7 +56,7 @@ script:
# install egroupware using MariaDB as domain "default"
# and add an admin user "demo" with password "guest"
- php doc/rpm-build/post_install.php --domain default
--source_dir `pwd` --start_db '' --autostart_db '' --start_webserver '' --webserver_user ''
--source_dir `pwd` --start_db '' --autostart_db '' --start_webserver '' --autostart_webserver '' --webserver_user ''
--admin_user demo --admin_passwd guest --admin_email noreply@example.com
# disable PostgreSQL install as it fails in Travis with Fatal error: Call to unimplemented native function pg_set_client_encoding
# install egroupware using PostgreSQL as domain "pgsql", need some specific handling we can not create users via sql
@ -79,8 +71,12 @@ script:
# --source_dir `pwd` --start_db '' --autostart_db '' --start_webserver '' --webserver_user ''
# Ubuntu has problems with #!/usr/bin/env php -dapc.enable=1, it stalls forever
- vendor/bin/phpunit -c doc -dapc.enable_cli=1
# output Apache error.log to diagnose PHP errors in requests send by unit-tests
- echo "travis_fold:start:SCRIPT folding starts"
- sudo cat /var/log/apache2/error.log
# do not run syntax check for hhvm, as it always fails / get terminated after 10m
- test $(phpenv version-name) = 'hhvm' || ./doc/php_syntax_check.sh
- ./doc/php_syntax_check.sh
- echo "travis_fold:start:SCRIPT folding ends"
cache:
directories:

View File

@ -10,9 +10,9 @@
*/
use EGroupware\Api;
use EGroupware\Api\Framework;
use EGroupware\Api\Acl;
use EGroupware\Api\Etemplate;
use EGroupware\Api\Framework;
/**
* UI for admin: edit/add account
@ -28,6 +28,13 @@ class admin_account
'delete' => true,
);
// Copying account uses addressbook fields, but we explicitly clear these
protected static $copy_clear_fields = array(
'account_firstname','account_lastname','account_fullname', 'person_id',
'account_id','account_lid',
'account_lastlogin','accountlastloginfrom','account_lastpwd_change'
);
/**
* Hook to edit account data via "Account" tab in addressbook edit dialog
*
@ -104,18 +111,24 @@ class admin_account
}
$readonlys['account_passwd'] = $readonlys['account_passwd2'] = true;
}
// save old values to only trigger save, if one of the following values change (contact data get saved anyway)
$preserve = empty($content['id']) ? array() :
array('old_account' => array_intersect_key($account, array_flip(array(
'account_lid', 'account_status', 'account_groups', 'anonymous', 'changepassword',
'mustchangepassword', 'account_primary_group', 'homedirectory', 'loginshell',
'account_expires', 'account_firstname', 'account_lastname', 'account_email'))),
'deny_edit' => $deny_edit);
if($content && $_GET['copy'])
{
$this->copy($content, $account, $preserve);
}
return array(
'name' => 'admin.account',
'prepend' => true,
'label' => 'Account',
'data' => $account,
// save old values to only trigger save, if one of the following values change (contact data get saved anyway)
'preserve' => empty($content['id']) ? array() :
array('old_account' => array_intersect_key($account, array_flip(array(
'account_lid', 'account_status', 'account_groups', 'anonymous', 'changepassword',
'mustchangepassword', 'account_primary_group', 'homedirectory', 'loginshell',
'account_expires', 'account_firstname', 'account_lastname', 'account_email'))),
'deny_edit' => $deny_edit),
'preserve' => $preserve,
'readonlys' => $readonlys,
'pre_save_callback' => $deny_edit ? null : 'admin_account::addressbook_pre_save',
);
@ -243,6 +256,35 @@ class admin_account
}
}
public function copy(array &$content, array &$account, array &$preserve)
{
// We skipped the addressbook copy, call it now
$ab_ui = new addressbook_ui();
$ab_ui->copy_contact($content, true);
// copy_contact() reset the owner, fix it
$content['owner'] = '0';
// Explicitly, always clear these
static $clear_content = Array(
'n_family','n_given','n_middle','n_suffix','n_fn','n_fileas',
'account_id','contact_id','id','etag','carddav_name','uid'
);
foreach($clear_content as $field)
{
$account[$field] ='';
$preserve[$field] = '';
}
$account['link_to']['to_id'] = 0;
unset($preserve['old_account']);
// Never copy these on an account
foreach(static::$copy_clear_fields as $field)
{
unset($account[$field]);
}
}
/**
* Delete an account
*

View File

@ -139,7 +139,10 @@ class admin_config
$_POST = array('newsettings' => &$_content['newsettings']);
// Remove actual files (cleanup) of deselected urls from login_background_file
$this->remove_anon_images(array_diff((array)$c->config_data['login_background_file'], $_content['newsettings']['login_background_file']));
if (!empty($c->config_data['login_background_file']))
{
$this->remove_anon_images(array_diff((array)$c->config_data['login_background_file'], (array)$_content['newsettings']['login_background_file']));
}
/* Load hook file with functions to validate each config (one/none/all) */
$errors = Api\Hooks::single(array(

View File

@ -11,10 +11,10 @@
*/
use EGroupware\Api;
use EGroupware\Api\Link;
use EGroupware\Api\Egw;
use EGroupware\Api\Etemplate;
use EGroupware\Api\Etemplate\Widget\Tree;
use EGroupware\Api\Link;
/**
* UI for admin
@ -154,6 +154,13 @@ class admin_ui
'onExecute' => 'javaScript:app.admin.account',
'group' => $group,
),
'copy' => array(
'caption' => 'Copy',
'url' => 'menuaction=addressbook.addressbook_ui.edit&makecp=1&contact_id=$id',
'onExecute' => 'javaScript:app.admin.account',
'allowOnMultiple' => false,
'icon' => 'copy',
),
);
// generate urls for add/edit accounts via addressbook
$edit = Link::get_registry('addressbook', 'edit');

View File

@ -760,11 +760,17 @@ var AdminApp = /** @class */ (function (_super) {
AdminApp.prototype.account = function (_action, _senders) {
var params = jQuery.extend({}, this.egw.link_get_registry('addressbook', 'edit'));
var popup = this.egw.link_get_registry('addressbook', 'edit_popup');
if (_action.id == 'add') {
params.owner = '0';
}
else {
params.account_id = _senders[0].id.split('::').pop(); // get last :: separated part
switch (_action.id) {
case 'add':
params.owner = '0';
break;
case 'copy':
params.owner = '0';
params.copy = true;
// Fall through
default:
params.account_id = _senders[0].id.split('::').pop(); // get last :: separated part
break;
}
this.egw.open_link(this.egw.link('/index.php', params), 'admin', popup);
};

View File

@ -888,14 +888,20 @@ class AdminApp extends EgwApp
var params = jQuery.extend({}, this.egw.link_get_registry('addressbook', 'edit'));
var popup = <string>this.egw.link_get_registry('addressbook', 'edit_popup');
if (_action.id == 'add')
switch(_action.id)
{
params.owner = '0';
}
else
{
params.account_id = _senders[0].id.split('::').pop(); // get last :: separated part
case 'add':
params.owner = '0';
break;
case 'copy':
params.owner = '0';
params.copy = true;
// Fall through
default:
params.account_id = _senders[0].id.split('::').pop(); // get last :: separated part
break;
}
this.egw.open_link(this.egw.link('/index.php', params), 'admin', popup);
}

View File

@ -3,58 +3,85 @@
<!-- $Id$ -->
<overlay>
<template id="admin.account.delete.delete" template="" lang="" group="0" version="18.1.001">
<vbox class="admin_account_delete">
<description value="Who would you like to transfer records owned by the deleted user to?" class="dialogHeader2"/>
<select-account id="new_owner" empty_label="Delete all records" class="dialogHeader3"/>
<description value="Automatically transfer entries owned by the user:"/>
<select id="delete_apps" rows="6" multiple="true" span="2"/>
<description value="Please manually deal with entries owned by the user:"/>
<grid id="counts" disabled="!@counts">
<columns>
<column width="150"/>
<column/>
</columns>
<rows>
<row>
<select-app id="${row}[app]" readonly="true"/>
<description id="${row}[count]"/>
</row>
</rows>
</grid>
<grid>
<columns>
<column width="150"/>
<column/>
</columns>
<rows>
<row>
<select-app value="filemanager" readonly="true"/>
<description value="Change owner of found files to the new user, and move the home folder to /home/new-user/old-home-username."/>
</row>
<row>
<select-app value="mail" readonly="true"/>
<description value="Please check email. It gets automatically deleted if email integration is used."/>
</row>
</rows>
</grid>
<description value="If you delete the user without selecting an account to move the data to, all entries get deleted!" font_style="b"/>
</vbox>
<grid width="100%" height="100%">
<columns>
<column width="60%"/>
<column/>
</columns>
<rows>
<row>
<description value="Who would you like to transfer records owned by the deleted user to?" class="dialogHeader2"/>
<select-account id="new_owner" empty_label="Delete all records" class="dialogHeader3"/>
</row>
<row>
<vbox>
<description value="Automatically transfer entries owned by the user:"/>
<select id="delete_apps" rows="6" multiple="true" span="2"/>
</vbox>
</row>
<row>
<vbox>
<description value="Please manually deal with entries owned by the user:"/>
<grid id="counts" disabled="!@counts">
<columns>
<column width="150"/>
<column/>
</columns>
<rows>
<row>
<select-app id="${row}[app]" readonly="true"/>
<description id="${row}[count]"/>
</row>
</rows>
</grid>
<grid>
<columns>
<column width="150"/>
<column/>
</columns>
<rows>
<row>
<select-app value="filemanager" readonly="true"/>
<description value="Change owner of found files to the new user, and move the home folder to /home/new-user/old-home-username."/>
</row>
<row>
<select-app value="mail" readonly="true"/>
<description value="Please check email. It gets automatically deleted if email integration is used."/>
</row>
</rows>
</grid>
<description value="If you delete the user without selecting an account to move the data to, all entries get deleted!" font_style="b"/>
</vbox>
</row>
</rows>
</grid>
</template>
<template id="admin.account.delete" template="" lang="" group="0" version="18.1.001">
<box class="dialogHeader">
<select-account id="account_id" readonly="true" label="Delete" onchange="var apps = widget.getRoot().getWidgetById('delete_apps'); apps.set_enabled(widget.getValue());"/>
</box>
<tabbox id="tabs" width="99%" tab_height="400px">
<tabs>
<tab id="main" label="Delete"/>
</tabs>
<tabpanels>
<template template="admin.account.delete.delete" width="99%"/>
</tabpanels>
</tabbox>
<hbox class="dialogFooterToolbar">
<button id="delete" label="Delete"/>
<button id="cancel" label="Cancel" onclick="window.close()"/>
</hbox>
<grid width="100%">
<columns>
<column width="100%"/>
</columns>
<rows>
<row class="dialogHeader">
<select-account id="account_id" readonly="true" label="Delete" onchange="var apps = widget.getRoot().getWidgetById('delete_apps'); apps.set_enabled(widget.getValue());"/>
</row>
<row>
<tabbox id="tabs" width="100%" tab_height="400px">
<tabs>
<tab id="main" label="Delete"/>
</tabs>
<tabpanels>
<template template="admin.account.delete.delete" width="100%"/>
</tabpanels>
</tabbox>
</row>
<row class="dialogFooterToolbar">
<hbox>
<button id="delete" label="Delete"/>
<button id="cancel" label="Cancel" onclick="window.close()"/>
</hbox>
</row>
</rows>
</grid>
</template>
</overlay>

View File

@ -58,10 +58,7 @@ td.admin_userAgent span {
overflow: hidden;
text-overflow: ellipsis;
}
.admin_account_delete > * {
padding: 8px;
}
form#admin-account-delete div.dialogHeader > label {
#admin-account-delete .dialogHeader label.et2_label {
font-size: 120%;
}
/* Global Category classes*/

View File

@ -66,10 +66,7 @@ td.admin_userAgent span {
overflow: hidden;
text-overflow: ellipsis;
}
.admin_account_delete > * {
padding: 8px;
}
form#admin-account-delete div.dialogHeader > label {
#admin-account-delete .dialogHeader label.et2_label {
font-size: 120%;
}
/* Global Category classes*/
@ -249,4 +246,7 @@ Admin command
filter: initial;
/* IE 6-9 */
}
#admin-account-delete .dialogFooterToolbar .et2_button_delete {
margin-left: 0;
}
}

View File

@ -59,4 +59,8 @@
/*filter grey*/
.img_filter_none;
}
#admin-account-delete .dialogFooterToolbar .et2_button_delete {
margin-left: 0;
}
}

View File

@ -28,7 +28,7 @@ class AclCommandTest extends CommandBase {
/**
* Create accounts for testing
*/
public function setUp()
protected function setUp() : void
{
parent::setUp();
@ -74,7 +74,7 @@ class AclCommandTest extends CommandBase {
$this->assertNotEmpty($this->account_id, 'Did not create test user account');
}
public function tearDown()
protected function tearDown() : void
{
// Delete the accounts we created
if($this->group_id)

View File

@ -23,7 +23,7 @@ class ConfigCommandTest extends CommandBase
// If we add a config, make sure we can delete it for clean up
protected $config_name = 'test_config';
public function tearDown()
protected function tearDown() : void
{
if($this->config_name)
{

View File

@ -29,7 +29,7 @@ class DeleteAccountCommandTest extends CommandBase {
'account_lastname' => 'Test'
);
public function setUp()
protected function setUp() : void
{
if(($account_id = $GLOBALS['egw']->accounts->name2id($this->account['account_lid'])))
{
@ -44,7 +44,7 @@ class DeleteAccountCommandTest extends CommandBase {
$this->assertNotEmpty($this->account_id, 'Did not create test user account');
}
public function tearDown()
protected function tearDown() : void
{
if($this->account_id && ($GLOBALS['egw']->accounts->id2name($this->account_id)))
{

View File

@ -28,7 +28,7 @@ class GroupCommandTest extends CommandBase {
'account_members' => array()
);
public function setUp()
protected function setUp() : void
{
// Can't set this until now - value is not available
$this->group['account_members'] = array($GLOBALS['egw_info']['user']['account_id']);
@ -39,7 +39,7 @@ class GroupCommandTest extends CommandBase {
$GLOBALS['egw']->accounts->delete($account_id);
}
}
public function tearDown()
protected function tearDown() : void
{
// Delete the accounts we created
if($this->group_id)

View File

@ -23,13 +23,13 @@ class PreferencesCommandTest extends CommandBase
// If we add a preference, make sure we can delete it for clean up
protected $preference_name = 'test_preference';
public function setUp()
protected function setUp() : void
{
Api\Cache::unsetInstance(Api\Preferences::class, 'forced');
Api\Cache::unsetInstance(Api\Preferences::class, 'default');
Api\Cache::unsetInstance(Api\Preferences::class, $GLOBALS['egw_info']['user']['account_id']);
}
public function tearDown()
protected function tearDown() : void
{
if($this->preference_name)
{

View File

@ -29,7 +29,7 @@ class UserCommandTest extends CommandBase {
'account_lastname' => 'Test'
);
public function tearDown()
protected function tearDown() : void
{
if($this->account_id)
{

View File

@ -114,7 +114,7 @@ var et2_htmlarea = /** @class */ (function (_super) {
valid_children: this.options.valid_children,
plugins: [
"print searchreplace autolink directionality ",
"visualblocks visualchars image link media template ",
"visualblocks visualchars image link media template fullscreen",
"codesample table charmap hr pagebreak nonbreaking anchor toc ",
"insertdatetime advlist lists textcolor wordcount imagetools ",
"colorpicker textpattern help paste code searchreplace tabfocus"
@ -440,14 +440,14 @@ var et2_htmlarea = /** @class */ (function (_super) {
*/
et2_htmlarea.TOOLBAR_EXTENDED = "fontselect fontsizeselect | bold italic strikethrough forecolor backcolor | " +
"link | alignleft aligncenter alignright alignjustify | numlist " +
"bullist outdent indent | removeformat | image";
"bullist outdent indent | removeformat | image | fullscreen";
/**
* arranged toolbars as advanced mode
* @constant
*/
et2_htmlarea.TOOLBAR_ADVANCED = "undo redo| formatselect | fontselect fontsizeselect | bold italic strikethrough forecolor backcolor | " +
"link | alignleft aligncenter alignright alignjustify | numlist " +
"bullist outdent indent ltr rtl | removeformat code| image | searchreplace";
"bullist outdent indent ltr rtl | removeformat code| image | searchreplace | fullscreen";
/**
* font size formats
* @constant

View File

@ -110,7 +110,7 @@ class et2_htmlarea extends et2_editableWidget implements et2_IResizeable
*/
public static readonly TOOLBAR_EXTENDED : string = "fontselect fontsizeselect | bold italic strikethrough forecolor backcolor | "+
"link | alignleft aligncenter alignright alignjustify | numlist "+
"bullist outdent indent | removeformat | image";
"bullist outdent indent | removeformat | image | fullscreen";
/**
* arranged toolbars as advanced mode
@ -118,7 +118,7 @@ class et2_htmlarea extends et2_editableWidget implements et2_IResizeable
*/
public static readonly TOOLBAR_ADVANCED : string = "undo redo| formatselect | fontselect fontsizeselect | bold italic strikethrough forecolor backcolor | "+
"link | alignleft aligncenter alignright alignjustify | numlist "+
"bullist outdent indent ltr rtl | removeformat code| image | searchreplace";
"bullist outdent indent ltr rtl | removeformat code| image | searchreplace | fullscreen";
/**
* font size formats
@ -232,7 +232,7 @@ class et2_htmlarea extends et2_editableWidget implements et2_IResizeable
valid_children : this.options.valid_children,
plugins: [
"print searchreplace autolink directionality ",
"visualblocks visualchars image link media template ",
"visualblocks visualchars image link media template fullscreen",
"codesample table charmap hr pagebreak nonbreaking anchor toc ",
"insertdatetime advlist lists textcolor wordcount imagetools ",
"colorpicker textpattern help paste code searchreplace tabfocus"

View File

@ -266,8 +266,8 @@ var etemplate2 = /** @class */ (function () {
etemplate2.prototype.bind_unload = function () {
if (this._etemplate_exec_id) {
this.destroy_session = jQuery.proxy(function (ev) {
var request = egw.json("EGroupware\\Api\\Etemplate::ajax_destroy_session", [this._etemplate_exec_id], null, null, false);
request.sendRequest();
// need to use async === "keepalive" to run via beforeunload
egw.json("EGroupware\\Api\\Etemplate::ajax_destroy_session", [this._etemplate_exec_id], null, null, "keepalive").sendRequest();
}, this);
if (!window.onbeforeunload) {
window.onbeforeunload = this.destroy_session;

View File

@ -335,9 +335,9 @@ export class etemplate2
{
this.destroy_session = jQuery.proxy(function (ev)
{
const request = egw.json("EGroupware\\Api\\Etemplate::ajax_destroy_session",
[this._etemplate_exec_id], null, null, false);
request.sendRequest();
// need to use async === "keepalive" to run via beforeunload
egw.json("EGroupware\\Api\\Etemplate::ajax_destroy_session",
[this._etemplate_exec_id], null, null, "keepalive").sendRequest();
}, this);
if (!window.onbeforeunload)

View File

@ -673,13 +673,14 @@ declare class JsonRequest
{
/**
* Sends the assembled request to the server
* @param {boolean} [async=false] Overrides async provided in constructor to give an easy way to make simple async requests
* @param {boolean|"keepalive"} _async true: asynchronious request, false: synchronious request,
* "keepalive": async. request with keepalive===true / sendBeacon, to be used in beforeunload event
* @param {string} method ='POST' allow to eg. use a (cachable) 'GET' request instead of POST
* @param {function} error option error callback(_xmlhttp, _err) used instead our default this.error
*
* @return {jqXHR} jQuery jqXHR request object
*/
sendRequest(async? : boolean, method? : "POST"|"GET", error? : Function)
sendRequest(async? : boolean|"keepalive", method? : "POST"|"GET", error? : Function)
/**
* Open websocket to push server (and keeps it open)
*
@ -721,14 +722,14 @@ declare interface IegwWndLocal extends IegwGlobal
* which handles the actual request. If the menuaction is a full featured
* url, this one will be used instead.
* @param _parameters which should be passed to the menuaction function.
* @param _async specifies whether the request should be asynchronous or
* not.
* @param {boolean|"keepalive"} _async true: asynchronious request, false: synchronious request,
* "keepalive": async. request with keepalive===true / sendBeacon, to be used in beforeunload event
* @param _callback specifies the callback function which should be
* called, once the request has been sucessfully executed.
* @param _context is the context which will be used for the callback function
* @param _sender is a parameter being passed to the _callback function
*/
json(_menuaction : string, _parameters? : any[], _callback? : Function, _context? : object, _async? : boolean, _sender?) : JsonRequest;
json(_menuaction : string, _parameters? : any[], _callback? : Function, _context? : object, _async? : boolean|"keepalive", _sender?) : JsonRequest;
/**
* Registers a new handler plugin.
*

View File

@ -7,7 +7,6 @@
* @link http://www.egroupware.org
* @author Andreas Stöckel (as AT stylite.de)
* @author Ralf Becker <RalfBecker@outdoor-training.de>
* @version $Id$
*/
/*egw:uses
@ -53,7 +52,8 @@ egw.extend('json', egw.MODULE_WND_LOCAL, function(_app, _wnd)
* @param {array} _parameters
* @param {function} _callback
* @param {object} _context
* @param {boolean} _async
* @param {boolean|"keepalive"} _async true: asynchronious request, false: synchronious request,
* "keepalive": async. request with keepalive===true / sendBeacon, to be used in boforeunload event
* @param {object} _sender
* @param {egw} _egw
*/
@ -154,11 +154,12 @@ egw.extend('json', egw.MODULE_WND_LOCAL, function(_app, _wnd)
/**
* Sends the assembled request to the server
* @param {boolean} [async=false] Overrides async provided in constructor to give an easy way to make simple async requests
* @param {boolean|"keepalive"} _async Overrides async provided in constructor: true: asynchronious request,
* false: synchronious request, "keepalive": async. request with keepalive===true / sendBeacon, to be used in beforeunload event
* @param {string} method ='POST' allow to eg. use a (cachable) 'GET' request instead of POST
* @param {function} error option error callback(_xmlhttp, _err) used instead our default this.error
*
* @return {jqXHR} jQuery jqXHR request object
* @return {jqXHR|boolean} jQuery jqXHR request object or for async==="keepalive" boolean is returned
*/
json_request.prototype.sendRequest = function(async, method, error)
{
@ -176,6 +177,15 @@ egw.extend('json', egw.MODULE_WND_LOCAL, function(_app, _wnd)
}
});
// send with keepalive===true or sendBeacon to be used in beforeunload event
if (this.async === "keepalive" && typeof navigator.sendBeacon !== "undefined")
{
const data = new FormData();
data.append('json_data', request_obj);
//(window.opener||window).console.log("navigator.sendBeacon", this.url, request_obj, data.getAll('json_data'));
return navigator.sendBeacon(this.url, data);
}
// Send the request via AJAX using the jquery ajax function
// we need to use jQuery of window of egw object, as otherwise the one from main window is used!
// (causing eg. apply from server with app.$app.method to run in main window instead of popup)

View File

@ -983,7 +983,7 @@ class Accounts
if (!is_array($app_users))
{
self::setup_cache();
$cache = &self::$cache['account_split'][$app_user];
$cache = &self::$cache['account_split'][$app_users];
if (is_array($cache))
{

View File

@ -125,7 +125,7 @@ class Ldap
*
* @var Api\Accounts
*/
private $frontend;
protected $frontend;
/**
* Instance of the ldap class

View File

@ -769,9 +769,10 @@ class Cache
if (is_null(Cache::$default_provider))
{
Cache::$default_provider =
function_exists('apcu_fetch') && Cache\Apcu::available() ? 'EGroupware\Api\Cache\Apcu' :
(function_exists('apc_fetch') && Cache\Apc::available() ? 'EGroupware\Api\Cache\Apc' :
'EGroupware\Api\Cache\Files');
PHP_SAPI === 'cli' ? 'EGroupware\Api\Cache\Files' :
(function_exists('apcu_fetch') && Cache\Apcu::available() ? 'EGroupware\Api\Cache\Apcu' :
(function_exists('apc_fetch') && Cache\Apc::available() ? 'EGroupware\Api\Cache\Apc' :
'EGroupware\Api\Cache\Files'));
}
//error_log('Cache::$default_provider='.array2string(Cache::$default_provider));

View File

@ -13,6 +13,9 @@
namespace EGroupware\Api\Cache;
// fix warning in tests, if memcache extension not available
if (!defined('MEMCACHE_COMPRESSED')) define('MEMCACHE_COMPRESSED', 2);
/**
* Caching provider storing data in memcached via PHP's memcache extension
*

View File

@ -548,13 +548,13 @@ abstract class Framework extends Framework\Extra
$var['logo_header'] = self::get_login_logo_or_bg_url('login_logo_header', 'logo');
}
$var['logo_url'] = $GLOBALS['egw_info']['server']['login_logo_url']?$GLOBALS['egw_info']['server']['login_logo_url']:'http://www.eGroupWare.org';
$var['logo_url'] = $GLOBALS['egw_info']['server']['login_logo_url']?$GLOBALS['egw_info']['server']['login_logo_url']:'http://www.egroupware.org';
if (substr($var['logo_url'],0,4) != 'http')
{
$var['logo_url'] = 'http://'.$var['logo_url'];
}
$var['logo_title'] = $GLOBALS['egw_info']['server']['login_logo_title']?$GLOBALS['egw_info']['server']['login_logo_title']:'www.eGroupWare.org';
$var['logo_title'] = $GLOBALS['egw_info']['server']['login_logo_title']?$GLOBALS['egw_info']['server']['login_logo_title']:'www.egroupware.org';
return $var;
}

View File

@ -98,6 +98,9 @@ class Sieve extends Horde\ManageSieve
//'logger' => new \admin_mail_logger('/tmp/sieve.log'),
);
}
// try "PLAIN" first, in case IMAP wrongly reports some digest, it does not (correctly) implement
array_unshift($this->supportedAuthMethods, self::AUTH_PLAIN);
parent::__construct($params);
$this->displayCharset = Translation::charset();

View File

@ -252,7 +252,13 @@ class Session
$config->value('num_unsuccessful_ip',$GLOBALS['egw_info']['server']['num_unsuccessful_ip']);
$config->value('install_id',$GLOBALS['egw_info']['server']['install_id']);
$config->value('max_history',$GLOBALS['egw_info']['server']['max_history']);
$config->save_repository();
try
{
$config->save_repository();
}
catch (Db\Exception $e) {
_egw_log_exception($e); // ignore exception, as it blocks session creation, if database is not writable
}
}
}
self::set_cookiedomain();
@ -544,9 +550,12 @@ class Session
// --> allows this stateless protocolls which use basic auth to use sessions!
if (($this->sessionid = self::get_sessionid(true)))
{
session_id($this->sessionid);
if (session_status() !== PHP_SESSION_ACTIVE) // gives warning including password
{
session_id($this->sessionid);
}
}
else
elseif (!headers_sent()) // only gives warnings, nothing we can do
{
self::cache_control();
session_start();
@ -557,6 +566,10 @@ class Session
}
$this->sessionid = session_id();
}
else
{
$this->sessionid = session_id() ?: Auth::randomstring(24);
}
$this->kp3 = Auth::randomstring(24);
$GLOBALS['egw_info']['user'] = $this->read_repositories();
@ -635,26 +648,30 @@ class Session
}
$GLOBALS['egw']->db->transaction_commit();
if ($GLOBALS['egw_info']['server']['usecookies'] && !$no_session)
if (!headers_sent())
{
self::egw_setcookie(self::EGW_SESSION_NAME,$this->sessionid);
self::egw_setcookie('kp3',$this->kp3);
self::egw_setcookie('domain',$this->account_domain);
}
if ($GLOBALS['egw_info']['server']['usecookies'] && !$no_session || isset($_COOKIE['last_loginid']))
{
self::egw_setcookie('last_loginid', $this->account_lid ,$now+1209600); /* For 2 weeks */
self::egw_setcookie('last_domain',$this->account_domain,$now+1209600);
}
if ($GLOBALS['egw_info']['server']['usecookies'] && !$no_session)
{
self::egw_setcookie(self::EGW_SESSION_NAME, $this->sessionid);
self::egw_setcookie('kp3', $this->kp3);
self::egw_setcookie('domain', $this->account_domain);
}
if ($GLOBALS['egw_info']['server']['usecookies'] && !$no_session || isset($_COOKIE['last_loginid']))
{
self::egw_setcookie('last_loginid', $this->account_lid, $now + 1209600); /* For 2 weeks */
self::egw_setcookie('last_domain', $this->account_domain, $now + 1209600);
}
// set new remember me token/cookie, if requested and necessary
$expiration = null;
if (($token = $this->checkSetRememberMeToken($remember_me, $_COOKIE[self::REMEMBER_ME_COOKIE], $expiration)))
{
self::egw_setcookie(self::REMEMBER_ME_COOKIE, $token, $expiration);
}
// set new remember me token/cookie, if requested and necessary
$expiration = null;
if (($token = $this->checkSetRememberMeToken($remember_me, $_COOKIE[self::REMEMBER_ME_COOKIE], $expiration)))
{
self::egw_setcookie(self::REMEMBER_ME_COOKIE, $token, $expiration);
}
if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check) successfull sessionid=$this->sessionid");
if (self::ERROR_LOG_DEBUG) error_log(__METHOD__ . "($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check) successfull sessionid=$this->sessionid");
}
elseif (self::ERROR_LOG_DEBUG) error_log(__METHOD__ . "($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check) could NOT set session cookies, headers already sent");
// hook called once session is created
Hooks::process(array(
@ -1649,6 +1666,8 @@ class Session
*/
private static function set_cookiedomain()
{
if (PHP_SAPI === "cli") return; // gives warnings and has no benefit
if ($GLOBALS['egw_info']['server']['cookiedomain'])
{
// Admin set domain, eg. .domain.com to allow egw.domain.com and www.domain.com
@ -1957,6 +1976,7 @@ class Session
case PHP_SESSION_DISABLED:
throw new \ErrorException('EGroupware requires PHP session extension!');
case PHP_SESSION_NONE:
if (headers_sent()) return false; // only gives warnings
ini_set('session.use_cookies',0); // disable the automatic use of cookies, as it uses the path / by default
session_name(self::EGW_SESSION_NAME);
if (($sessionid = self::get_sessionid()))

View File

@ -207,7 +207,7 @@ class Sharing
header('WWW-Authenticate: Basic realm="'.$realm.'"');
return static::share_fail(
'401 Unauthorized',
"<html>\n<head>\n<title>401 Unauthorized</title>\n<body>\nAuthorization failed.\n</body>\n</html>\n"
"Authorization failed."
);
}

View File

@ -395,7 +395,11 @@ class Translation
static function &load_app($app,$lang)
{
//$start = microtime(true);
if (is_null(self::$db)) self::init(false);
if (!isset(self::$db))
{
self::init(false);
if (!isset(self::$db)) return;
}
$loaded = array();
foreach(self::$db->select(self::LANG_TABLE,'message_id,content',array(
'lang' => $lang,

View File

@ -90,9 +90,8 @@ class Sharing extends \EGroupware\Api\Sharing
*/
public static function setup_share($keep_session, &$share)
{
// need to reset fs_tab, as resolve_url does NOT work with just share mounted
if (count($GLOBALS['egw_info']['server']['vfs_fstab']) <= 1)
if (empty($GLOBALS['egw_info']['server']['vfs_fstab']) || count($GLOBALS['egw_info']['server']['vfs_fstab']) <= 1)
{
unset($GLOBALS['egw_info']['server']['vfs_fstab']); // triggers reset of fstab in mount()
$GLOBALS['egw_info']['server']['vfs_fstab'] = Vfs::mount();

View File

@ -367,7 +367,7 @@ function function_backtrace($remove=0)
return $_GET['menuaction'] ? $_GET['menuaction'] : str_replace(EGW_SERVER_ROOT,'',$_SERVER['SCRIPT_FILENAME']);
}
if (!function_exists('lang') || defined('NO_LANG')) // setup declares an own version
if (!function_exists('lang') && !defined('NO_LANG')) // setup declares an own version
{
/**
* function to handle multilanguage support

377
api/tests/CalDAVTest.php Normal file
View File

@ -0,0 +1,377 @@
<?php
/**
* CalDAV tests base class
*
* @link http://www.egroupware.org
* @author Ralf Becker
* @package api
* @subpackage caldav
* @copyright (c) 2020 by Ralf Becker <rb@egroupware.org>
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
*/
namespace EGroupware\Api;
// so tests can run standalone
require_once __DIR__.'/../src/loader/common.php'; // autoloader
use PHPUnit\Framework\TestCase;
use GuzzleHttp\Client, GuzzleHttp\RequestOptions;
use Horde_Icalendar, Horde_Icalendar_Exception;
use Psr\Http\Message\ResponseInterface;
/**
* Abstract CalDAVTest using GuzzleHttp\Client against EGroupware CalDAV/CardDAV server
*
* @see http://docs.guzzlephp.org/en/v6/quickstart.html
*
* @package EGroupware\Api
*/
abstract class CalDAVTest extends TestCase
{
/**
* Base URL of CalDAV server
*/
const CALDAV_BASE = 'http://localhost/egroupware/groupdav.php';
/**
* Get full URL for a CalDAV path
*
* @param string $path CalDAV path
* @return string URL
*/
protected function url($path='/')
{
$base = self::CALDAV_BASE;
if (!empty($GLOBALS['EGW_DOMAIN']) && $GLOBALS['EGW_DOMAIN'] !== 'default')
{
$base = str_replace('localhost', $GLOBALS['EGW_DOMAIN'], $base);
}
return $base.$path;
}
/**
* Default options for GuzzleHttp\Client
*
* @var array
* @see http://docs.guzzlephp.org/en/v6/request-options.html
*/
protected $client_options = [
RequestOptions::HTTP_ERRORS => false, // return all HTTP status, not throwing exceptions
RequestOptions::HEADERS => [
'Cookie' => 'XDEBUG_SESSION=PHPSTORM',
//'User-Agent' => 'CalDAVSynchronizer',
],
];
/**
* Get HTTP client for tests
*
* It will use by default the always existing user "demo" with password "guest" (use [] to NOT authenticate).
* Additional users need to be created with $this->createUser("name").
*
* @param string|array $user_or_options ='demo' string with account_lid of user for authentication or array of options
* @return Client
* @see http://docs.guzzlephp.org/en/v6/request-options.html
* @see http://docs.guzzlephp.org/en/v6/quickstart.html
*/
protected function getClient($user_or_options='demo')
{
if (!is_array($user_or_options))
{
$user_or_options = $this->auth($user_or_options);
}
return new Client(array_merge($this->client_options, $user_or_options));
}
/**
* Create a number of users with optional ACL rights too
*
* Example with boss granting secretary full rights on his calendar, plus one other user:
*
* $users = [
* 'boss' => [],
* 'secretary' => ['rights' => ['boss' => Acl::READ|Acl::ADD|Acl::EDIT|Acl::DELETE]],
* 'other' => [],
* ];
* self::createUsersACL($users);
*
* @param array& $users $account_lid => array with values for keys with (defaults) "firstname" ($_acount_lid), "lastname" ("User"),
* "email" ("$_account_lid@example.org"), "password" (random string), "primary_group" ("NoGroups" to not set rights)
* "rights" array with $grantee => $rights pairs (need to be created before!)
* @param string $app app to create the rights for, default "calendar"
* @throws \Exception
*/
protected function createUsersACL(array &$users, $app='calendar')
{
foreach($users as $user => $data)
{
$data['id'] = self::createUser($user, $data);
foreach($data['rights'] ?? [] as $grantee => $rights)
{
self::addAcl('calendar', $data['id'], $grantee, $rights);
}
}
}
/**
* Array to track created users for tearDown and authentication
*
* @var array $account_lid => array with other data pairs
*/
private static $created_users = [];
/**
* Create a user
*
* Created users are automatic deleted in tearDown() and can be passed to auth() or getClient() methods.
* Users have random passwords to force new/different sessions!
*
* @param string $_account_lid
* @param array& $data =[] values for keys with (defaults) "firstname" ($_acount_lid), "lastname" ("User"),
* "email" ("$_account_lid@example.org"), "password" (random string), "primary_group" ("NoGroups" to not set rights)
* on return: with defaults set
* @return int account_id of created user
* @throws \Exception
*/
protected static function createUser($_account_lid, array &$data=[])
{
// add some defaults
$data = array_merge([
'firstname' => ucfirst($_account_lid),
'lastname' => 'User',
'email' => $_account_lid.'@example.org',
'password' => 'secret',//Auth::randomstring(12),
'primary_group' => 'NoGroup',
], $data);
$data['id'] = self::getSetup()->add_account($_account_lid, $data['firstname'], $data['lastname'],
$data['password'], $data['primary_group'], false, $data['email']);
// give use run rights for CalDAV apps, as NoGroup does NOT!
self::addAcl(['groupdav','calendar','infolog','addressbook'], 'run', $data['id']);
self::$created_users[$_account_lid] = $data;
return $data['id'];
}
/**
* Get authentication information for given user to use
*
* @param string $_account_lid ='demo'
* @return array
*/
protected function auth($_account_lid='demo')
{
if ($_account_lid === 'demo')
{
$password = 'guest';
}
elseif (!isset(self::$created_users[$_account_lid]))
{
throw new \InvalidArgumentException("No user '$_account_lid' exist, need to create it with createUser('$_account_lid')");
}
else
{
$password = self::$created_users[$_account_lid]['password'];
}
return [RequestOptions::AUTH => [$_account_lid, $password]];
}
/**
* Tear down:
* - delete users created by createUser() incl. their ACL and data
*
* @ToDo: implement eg. with admin_cmd_delete_user to also delete ACL and data
*/
public static function tearDownAfterClass() : void
{
$setup = self::getSetup();
foreach(self::$created_users as $account_lid => $data)
{
// if ($id) $setup->accounts->delete($data['id']);
unset(self::$created_users[$account_lid]);
}
}
/**
* Add ACL rights
*
* @param string|array $apps app-names
* @param string $location eg. "run"
* @param int|string $account accountid or account_lid
* @param int $rights rights to set, default 1
*/
protected static function addAcl($apps, $location, $account, $rights=1)
{
return self::getSetup()->add_acl($apps, $location, $account, $rights);
}
/**
* Return instance of setup object eg. to create users
*
* @return \setup
*/
private static function getSetup()
{
static $setup=null;
if (!isset($setup))
{
if (!isset($_REQUEST['domain']))
{
$_REQUEST['domain'] = $GLOBALS['EGW_DOMAIN'] ?? 'default';
}
$_REQUEST['ConfigDomain'] = $_REQUEST['domain'];
$GLOBALS['egw_info'] = array(
'flags' => array(
'noheader' => True,
'nonavbar' => True,
'currentapp' => 'setup',
'noapi' => True
));
if (file_exists(__DIR__ . '/../../header.inc.php'))
{
include_once(__DIR__ . '/../../header.inc.php');
}
$setup = new \setup();
}
return $setup;
}
/**
* Check HTTP status in response
*
* @param int|array $expected one or more valid status codes
* @param ResponseInterface $response
* @param string $message ='' additional message to prefix result message
*/
protected function assertHttpStatus($expected, ResponseInterface $response, $message='')
{
$status = $response->getStatusCode();
$this->assertEquals(in_array($status, (array)$expected) ? $status : ((array)$expected)[0], $status,
(!empty($message) ? $message.': ' : ''). 'Expected HTTP status: '.json_encode($expected).
", Server returned: $status ".$response->getReasonPhrase());
}
/**
* Asserts an iCal file matches an expected one taking into account $_overwrites
*
* @param string $_expected
* @param string $_acctual
* @param string $_message
* @param array $_overwrites =[] eg. ['vEvent' => [['ATTENDEE' => ['mailto:boss@example.org' => ['PARTSTAT' => 'DECLINED']]]]]
* (first vEvent attendee with value 'mailto:boss@...' has param 'PARTSTAT=DECLINED')
* @throws Horde_Icalendar_Exception
*/
protected function assertIcal($_expected, $_acctual, $_message=null, $_overwrites=[])
{
// enable to see full iCals
//$this->assertEquals($_expected, (string)$_acctual, $_message.": iCal not byte-by-byte identical");
$expected = new Horde_Icalendar();
$expected->parsevCalendar($_expected);
$acctual = new Horde_Icalendar();
$acctual->parsevCalendar($_acctual);
if (($msgs = $this->checkComponentEqual($expected, $acctual, $_overwrites)))
{
$this->assertEquals($_expected, (string)$_acctual, ($_message ? $_message.":\n" : '').implode("\n", $msgs));
}
else
{
$this->assertTrue(true); // due to $_overwrite probable $_expected !== $_acctual
}
}
/**
* Check two iCal components are equal modulo overwrites / expected difference
*
* Only a whitelist of attributes per component are checked, see $component_attrs2check variable.
*
* @param Horde_Icalendar $_expected
* @param Horde_Icalendar $_acctual
* @param string $_message
* @param array $_overwrites =[] eg. ['ATTENDEE' => ['boss@example.org' => ['PARTSTAT' => 'DECLINED']]]
* @throws Horde_Icalendar_Exception
* @return array message(s) what's not equal
*/
protected function checkComponentEqual(Horde_Icalendar $_expected, Horde_Icalendar $_acctual, $_overwrites=[])
{
// only following attributes in these components are checked:
static $component_attrs2check = [
'vcalendar' => ['VERSION'],
'vTimeZone' => ['TZID'],
'vEvent' => ['UID', 'SUMMARY', 'LOCATION', 'DESCRIPTION', 'DTSTART', 'DTEND', 'ORGANIZER', 'ATTENDEE'],
];
if ($_expected->getType() !== $_acctual->getType())
{
return ["component type not equal"];
}
$msgs = [];
foreach ($component_attrs2check[$_expected->getType()] ?? [] as $attr)
{
$acctualAttrs = $_acctual->getAllAttributes($attr);
foreach($_expected->getAllAttributes($attr) as $expectedAttr)
{
$found = false;
foreach($acctualAttrs as $acctualAttr)
{
if (count($acctualAttrs) === 1 || $expectedAttr['value'] === $acctualAttr['value'])
{
$found = true;
break;
}
}
if (!$found)
{
$msgs[] = "No $attr {$expectedAttr['value']} found";
continue;
}
// remove / ignore X-parameters, eg. X-EGROUPWARE-UID in ATTENDEE or ORGANIZER
$acctualAttr['params'] = array_filter($acctualAttr['params'], function ($key) {
return substr($key, 0, 2) !== 'X-';
}, ARRAY_FILTER_USE_KEY);
if (isset($_overwrites[$attr]) && is_scalar($_overwrites[$attr]))
{
$expectedAttr = [
'name' => $attr,
'value' => $_overwrites[$attr],
'values' => [$_overwrites[$attr]],
'params' => [],
];
}
elseif (isset($_overwrites[$attr]) && is_array($_overwrites[$attr]))
{
foreach ($_overwrites[$attr] as $value => $params)
{
if ($value === $expectedAttr['value'])
{
$expectedAttr['params'] = array_merge($expectedAttr['params'], $params);
}
}
}
if ($expectedAttr != $acctualAttr)
{
$this->assertEquals($expectedAttr, $acctualAttr, "$attr not equal");
$msgs[] = "$attr not equal";
}
}
}
// check sub-components, overrites use an index by type eg. 1. vEvent: ['vEvent'=>[[<overwrites for 1. vEvent]]]
$idx_by_type = [];
foreach($_expected->getComponents() as $idx => $component)
{
if (!isset($idx_by_type[$type = $component->getType()])) $idx_by_type[$type] = 0;
$msgs = array_merge($msgs, $this->checkComponentEqual($component, $_acctual->getComponent($idx),
$_overwrites[$type][$idx_by_type[$type]] ?? []));
$idx_by_type[$type]++;
}
return $msgs;
}
}

View File

@ -25,7 +25,7 @@ class DateTimeTest extends TestCase {
/**
* Work in server time, so tests match expectations
*/
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();
@ -41,7 +41,7 @@ class DateTimeTest extends TestCase {
// Set user time to server time for consistency
DateTime::setUserPrefs(date_default_timezone_get());
}
public static function tearDownAfterClass()
public static function tearDownAfterClass() : void
{
// Reset
DateTime::setUserPrefs(static::$usertime->getName());

View File

@ -59,7 +59,7 @@ class SchemaTest extends LoggedInTest {
/**
* Get a database connection
*/
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();

View File

@ -26,11 +26,11 @@ class EntryTest extends \EGroupware\Api\Etemplate\WidgetBaseTest {
const TEST_TEMPLATE = 'api.entry_test_contact';
public static function setUpBeforeClass() {
public static function setUpBeforeClass() : void {
parent::setUpBeforeClass();
}
public function tearDown()
protected function tearDown() : void
{
// Delete all elements
foreach($this->elements as $id)

View File

@ -29,7 +29,7 @@ class DateTest extends \EGroupware\Api\Etemplate\WidgetBaseTest
/**
* Work in server time, so tests match expectations
*/
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();
@ -41,7 +41,7 @@ class DateTest extends \EGroupware\Api\Etemplate\WidgetBaseTest
date_default_timezone_set('UTC');
DateTime::$server_timezone = new \DateTimeZone('UTC');
}
public static function tearDownAfterClass()
public static function tearDownAfterClass() : void
{
// Reset
DateTime::setUserPrefs(static::$usertime->getName());

View File

@ -39,7 +39,7 @@ abstract class WidgetBaseTest extends \EGroupware\Api\LoggedInTest {
protected $ajax_response = null;
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();
@ -49,12 +49,12 @@ abstract class WidgetBaseTest extends \EGroupware\Api\LoggedInTest {
new \EGroupware\Api\Etemplate();
}
public function setUp()
protected function setUp() : void
{
// Mock AJAX response
$this->ajax_response = $this->mock_ajax_response();
}
public function tearDown()
protected function tearDown() : void
{
// Clean up AJAX response
$this->ajax_response->initResponseArray();

View File

@ -35,7 +35,7 @@ abstract class LoggedInTest extends TestCase
/**
* Start session once before each test case
*/
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
try
{
@ -63,7 +63,7 @@ abstract class LoggedInTest extends TestCase
}
}
public function assertPreConditions()
protected function assertPreConditions() : void
{
// Do some checks to make sure things we expect are there
$this->assertTrue(static::sanity_check(), 'Unable to connect to Egroupware - failed sanity check');
@ -72,7 +72,7 @@ abstract class LoggedInTest extends TestCase
/**
* End session when done - every test class gets its own session
*/
public static function tearDownAfterClass()
public static function tearDownAfterClass() : void
{
if($GLOBALS['egw'])
{
@ -125,11 +125,6 @@ abstract class LoggedInTest extends TestCase
'passwd_type' => 'text',
);
if (ini_get('session.save_handler') == 'files' && !is_writable(ini_get('session.save_path')) && is_dir('/tmp') && is_writable('/tmp'))
{
ini_set('session.save_path','/tmp'); // regular users may have no rights to apache's session dir
}
if(!$info)
{
$info = array(

View File

@ -27,7 +27,7 @@ class BaseTest extends TestCase
*/
private $storage;
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
if (ini_get('session.save_handler') == 'files' && !is_writable(ini_get('session.save_path')) && is_dir('/tmp') && is_writable('/tmp'))
{
@ -49,12 +49,12 @@ class BaseTest extends TestCase
self::$db->connect();
}
protected function setUp()
protected function setUp() : void
{
$this->storage = new Api\Storage\Base('test', 'egw_test', self::$db);
}
protected function assertPreConditions()
protected function assertPreConditions() : void
{
$tables = self::$db->table_names(true);
$this->assertContains('egw_test', $tables, 'Could not find DB table "egw_test", make sure test app is installed');

View File

@ -35,7 +35,7 @@ class CustomfieldsTest extends LoggedInTest
'private' => array()
);
public function assertPreConditions()
protected function assertPreConditions() : void
{
parent::assertPreConditions();
$tables = $GLOBALS['egw']->db->table_names(true);

View File

@ -61,7 +61,7 @@ class SharingBase extends LoggedInTest
'maxdepth' => 5
);
public function setUp()
protected function setUp() : void
{
// Check we have basic access
if(!is_readable($GLOBALS['egw_info']['server']['files_dir']))
@ -75,7 +75,7 @@ class SharingBase extends LoggedInTest
}
public function tearDown()
protected function tearDown() : void
{
LoggedInTest::tearDownAfterClass();
LoggedInTest::setupBeforeClass();
@ -239,7 +239,7 @@ class SharingBase extends LoggedInTest
break;
case Sharing::WRITABLE:
// Root is not writable
if($file == '/') continue;
if($file == '/') break;
$this->assertTrue(Vfs::is_writable($file), $file . ' was not writable');
if(!Vfs::is_dir($file))

View File

@ -21,7 +21,7 @@ use PHPUnit\Framework\TestCase as TestCase;
class SecurityTest extends TestCase {
public function setUp()
protected function setUp() : void
{
// _check_script_tag uses HtmLawed, which calls GLOBALS['egw']->link()
$GLOBALS['egw'] = $this->getMockBuilder('Egw')
@ -30,7 +30,7 @@ class SecurityTest extends TestCase {
->getMock();
}
public function tearDown()
protected function tearDown() : void
{
unset($GLOBALS['egw_inset_vars']);

View File

@ -1338,7 +1338,7 @@ class calendar_bo
elseif ($grants[$uid] & Acl::READ)
{
// if we have a READ grant from a participant, we dont give an implicit privat grant too
$grant |= Acl::READ;
$grant |= self::ACL_FREEBUSY | Acl::READ;
// we cant break here, as we might be a participant too, and would miss the privat grant
}
elseif (!is_numeric($uid))

View File

@ -1375,35 +1375,56 @@ class calendar_groupdav extends Api\CalDAV\Handler
return true; // simply ignore DELETE in inbox for now
}
$return_no_access = true; // to allow to check if current use is a participant and reject the event for him
if (!is_array($event = $this->_common_get_put_delete('DELETE',$options,$id,$return_no_access)) || !$return_no_access ||
// Work around problems with Outlook CalDAV Synchroniser (https://caldavsynchronizer.org/)
// - sends a DELETE to reject a meeting request --> deletes event for all participants, if user has delete rights on the calendar
// --> only set status for everyone else but the organizer
self::get_agent() == 'caldavsynchronizer' && is_array($event) && $event['owner'] != $user)
$event = $this->_common_get_put_delete('DELETE',$options,$id,$return_no_access);
// no event found --> 404 Not Found
if (!is_array($event))
{
if (is_array($event) && (!$return_no_access || $event['owner'] != $user))
$ret = $event;
error_log("_common_get_put_delete('DELETE', ..., $id) user=$user, return_no_access=".array2string($return_no_access)." returned ".array2string($event));
}
// Work around problems with Outlook CalDAV Synchronizer (https://caldavsynchronizer.org/)
// - sends a DELETE to reject a meeting request --> deletes event for all participants, if user has delete rights from the organizer
// --> only set status for everyone else but the organizer
// OR no delete rights and deleting an event in someone else calendar --> check if calendar owner is a participant --> reject him
elseif ((!$return_no_access || (self::get_agent() === 'caldavsynchronizer' && $event['owner'] != $user)) &&
// check if current user has edit rights for calendar of $user, can change status / reject invitation for him
$this->bo->check_perms(Acl::EDIT, 0, $user))
{
// check if user is a participant or one of the groups he is a member of --> reject the meeting request
$ret = '403 Forbidden';
$memberships = $GLOBALS['egw']->accounts->memberships($user, true);
foreach(array_keys($event['participants']) as $uid)
{
// check if user is a participant or one of the groups he is a member of --> reject the meeting request
$ret = '403 Forbidden';
$memberships = $GLOBALS['egw']->accounts->memberships($this->bo->user, true);
foreach(array_keys($event['participants']) as $uid)
if ($user == $uid || in_array($uid, $memberships))
{
if ($this->bo->user == $uid || in_array($uid, $memberships))
{
$this->bo->set_status($event,$this->bo->user, 'R');
$ret = true;
break;
}
$this->bo->set_status($event, $user, 'R');
$ret = true;
break;
}
}
else
}
// current user has no delete rights for event --> reject invitation, if he is a participant
elseif (!$return_no_access)
{
// check if current user is a participant or one of the groups he is a member of --> reject the meeting request
$ret = '403 Forbidden';
$memberships = $GLOBALS['egw']->accounts->memberships($this->bo->user, true);
foreach(array_keys($event['participants']) as $uid)
{
$ret = $event;
if ($this->bo->user == $uid || in_array($uid, $memberships))
{
$this->bo->set_status($event, $this->bo->user, 'R');
$ret = true;
break;
}
}
}
// we have delete rights on the event and (try to) delete it
else
{
$ret = $this->bo->delete($event['id']);
if (!$ret) { error_log("delete($event[id]) returned FALSE"); $ret = '400 Failed to delete event';}
}
if ($this->debug) error_log(__METHOD__."(,$id) return_no_access=$return_no_access, event[participants]=".array2string(is_array($event)?$event['participants']:null).", user={$this->bo->user} --> return ".array2string($ret));
return $ret;

View File

@ -448,7 +448,7 @@ class calendar_ical extends calendar_boupdate
$quantity = $role = null;
calendar_so::split_status($status, $quantity, $role);
// do not include event owner/ORGANIZER as participant in his own calendar, if he is only participant
if (count($event['participants']) == 1 && $event['owner'] == $uid) continue;
if (count($event['participants']) == 1 && $event['owner'] == $uid && $uid == $this->user) continue;
if (!($info = $this->resource_info($uid))) continue;
@ -497,15 +497,15 @@ class calendar_ical extends calendar_boupdate
{
$user = $this->resource_info($this->user);
$attributes['ATTENDEE'][] = 'mailto:' . $user['email'];
$parameters['ATTENDEE'][] = array(
'CN' => $user['name'],
'ROLE' => 'REQ-PARTICIPANT',
$parameters['ATTENDEE'][] = array(
'CN' => $user['name'],
'ROLE' => 'REQ-PARTICIPANT',
'PARTSTAT' => 'NEEDS-ACTION',
'CUTYPE' => 'INDIVIDUAL',
'RSVP' => 'TRUE',
'X-EGROUPWARE-UID' => (string)$this->user,
);
$event['participants'][$this->user] = true;
);
$event['participants'][$this->user] = true;
}
break;
case 'r':
@ -561,33 +561,33 @@ class calendar_ical extends calendar_boupdate
}
break;
case 'ORGANIZER':
if (!$organizerURL)
{
$organizerCN = '"' . trim($GLOBALS['egw']->accounts->id2name($event['owner'],'account_firstname')
. ' ' . $GLOBALS['egw']->accounts->id2name($event['owner'],'account_lastname')) . '"';
$organizerEMail = $GLOBALS['egw']->accounts->id2name($event['owner'],'account_email');
if ($version == '1.0')
{
$organizerURL = trim($organizerCN . (empty($organizerURL) ? '' : ' <' . $organizerURL .'>'));
}
else
{
$organizerURL = empty($organizerEMail) ? '' : 'mailto:' . $organizerEMail;
}
$organizerUID = $event['owner'];
}
// do NOT use ORGANIZER for events without further participants or a different organizer
if (count($event['participants']) > 1 || !isset($event['participants'][$event['owner']]))
{
$attributes['ORGANIZER'] = $organizerURL;
$parameters['ORGANIZER']['CN'] = $organizerCN;
if (!empty($organizerUID))
{
$parameters['ORGANIZER']['X-EGROUPWARE-UID'] = $organizerUID;
}
}
break;
case 'ORGANIZER':
if (!$organizerURL)
{
$organizerCN = '"' . trim($GLOBALS['egw']->accounts->id2name($event['owner'],'account_firstname')
. ' ' . $GLOBALS['egw']->accounts->id2name($event['owner'],'account_lastname')) . '"';
$organizerEMail = $GLOBALS['egw']->accounts->id2name($event['owner'],'account_email');
if ($version == '1.0')
{
$organizerURL = trim($organizerCN . (empty($organizerURL) ? '' : ' <' . $organizerURL .'>'));
}
else
{
$organizerURL = empty($organizerEMail) ? '' : 'mailto:' . $organizerEMail;
}
$organizerUID = $event['owner'];
}
// do NOT use ORGANIZER for events without further participants or a different organizer
if (count($event['participants']) > 1 || !isset($event['participants'][$event['owner']]) || $event['owner'] != $this->user)
{
$attributes['ORGANIZER'] = $organizerURL;
$parameters['ORGANIZER']['CN'] = $organizerCN;
if (!empty($organizerUID))
{
$parameters['ORGANIZER']['X-EGROUPWARE-UID'] = $organizerUID;
}
}
break;
case 'DTSTART':
if (empty($event['whole_day']))
@ -1014,12 +1014,12 @@ class calendar_ical extends calendar_boupdate
foreach (is_array($value) && $parameters[$key]['VALUE']!='DATE' ? $value : array($value) as $valueID => $valueData)
{
$valueData = Api\Translation::convert($valueData,Api\Translation::charset(),$charset);
$paramData = (array) Api\Translation::convert(is_array($value) ?
$parameters[$key][$valueID] : $parameters[$key],
Api\Translation::charset(),$charset);
$valuesData = (array) Api\Translation::convert($values[$key],
Api\Translation::charset(),$charset);
$content = $valueData . implode(';', $valuesData);
$paramData = (array) Api\Translation::convert(is_array($value) ?
$parameters[$key][$valueID] : $parameters[$key],
Api\Translation::charset(),$charset);
$valuesData = (array) Api\Translation::convert($values[$key],
Api\Translation::charset(),$charset);
$content = $valueData . implode(';', $valuesData);
if ($version == '1.0' && (preg_match('/[^\x20-\x7F]/', $content) ||
($paramData['CN'] && preg_match('/[^\x20-\x7F]/', $paramData['CN']))))
@ -2325,7 +2325,7 @@ class calendar_ical extends calendar_boupdate
* @param string|resource $_vcalData
* @param string $principalURL ='' Used for CalDAV imports
* @param string $charset The encoding charset for $text. Defaults to
* utf-8 for new format, iso-8859-1 for old format.
* utf-8 for new format, iso-8859-1 for old format.
* @return Iterator|array|boolean Iterator if resource given or array of events on success, false on failure
*/
function icaltoegw($_vcalData, $principalURL='', $charset=null)
@ -2744,10 +2744,10 @@ class calendar_ical extends calendar_boupdate
// work around Ligthning sending @ as %40
$attributes['value'] = str_replace('%40', '@', $attributes['value']);
if (isset($attributes['params']['PARTSTAT']))
{
$attributes['params']['STATUS'] = $attributes['params']['PARTSTAT'];
}
if (isset($attributes['params']['STATUS']))
{
$attributes['params']['STATUS'] = $attributes['params']['PARTSTAT'];
}
if (isset($attributes['params']['STATUS']))
{
$status = $this->status_ical2egw[strtoupper($attributes['params']['STATUS'])];
if (empty($status)) $status = 'X';
@ -3165,7 +3165,7 @@ class calendar_ical extends calendar_boupdate
array2string($event)."\n",3,$this->logfile);
}
//Horde::logMessage("vevent2egw:\n" . print_r($event, true),
// __FILE__, __LINE__, PEAR_LOG_DEBUG);
// __FILE__, __LINE__, PEAR_LOG_DEBUG);
return $event;
}

View File

@ -11,10 +11,10 @@
*/
use EGroupware\Api;
use EGroupware\Api\Link;
use EGroupware\Api\Framework;
use EGroupware\Api\Acl;
use EGroupware\Api\Etemplate;
use EGroupware\Api\Framework;
use EGroupware\Api\Link;
/**
* Class to generate the calendar listview and the search
@ -137,7 +137,7 @@ class calendar_uilist extends calendar_ui
'filter_no_lang' => True, // I set no_lang for filter (=dont translate the options)
'no_filter2' => True, // I disable the 2. filter (params are the same as for filter)
'no_cat' => True, // I disable the cat-selectbox
'filter' => 'after',
'filter' => 'month',
'order' => 'cal_start',// IO name of the column to sort after (optional for the sortheaders)
'sort' => 'ASC',// IO direction of the sort: 'ASC' or 'DESC'
'default_cols' => '!week,weekday,cal_title,cal_description,recure,cal_location,cal_owner,cat_id,pm_id',
@ -150,6 +150,12 @@ class calendar_uilist extends calendar_ui
}
$content['nm']['actions'] = $this->get_actions();
// Skip first load if view is not listview
if($this->view && $this->view !== 'listview')
{
$content['nm']['num_rows'] = 0;
}
if (isset($_GET['filter']) && in_array($_GET['filter'],array_keys($this->date_filters)))
{
$content['nm']['filter'] = $_GET['filter'];
@ -353,6 +359,7 @@ class calendar_uilist extends calendar_ui
break;
case 'month':
default:
$this->first = $this->bo->date2array($params['date'] ? $params['date'] : $this->date);
$this->first['day'] = 1;
unset($this->first['raw']);
@ -368,9 +375,7 @@ class calendar_uilist extends calendar_ui
$params['enddate'] = Api\DateTime::to($this->last, Api\DateTime::ET2);
break;
// fall through to after given date
case 'after':
default:
$this->date = $params['startdate'] ? Api\DateTime::to($params['startdate'],'ts') : $this->date;
$label = lang('After %1',$this->bo->long_date($this->date));
$search_params['start'] = $this->date;

View File

@ -391,7 +391,7 @@ var CalendarApp = /** @class */ (function (_super) {
//set onbeforeunload with json request to send request when the window gets close by X button
if (content.data.lock_token) {
window.onbeforeunload = function () {
this.egw.json('calendar.calendar_uiforms.ajax_unlock', [content.data.id, content.data.lock_token], null, true, null, null).sendRequest(true);
this.egw.json('calendar.calendar_uiforms.ajax_unlock', [content.data.id, content.data.lock_token], null, true, "keepalive", null).sendRequest();
};
}
}

View File

@ -292,7 +292,7 @@ class CalendarApp extends EgwApp
{
window.onbeforeunload = function () {
this.egw.json('calendar.calendar_uiforms.ajax_unlock',
[content.data.id, content.data.lock_token],null,true,null,null).sendRequest(true);
[content.data.id, content.data.lock_token],null,true,"keepalive",null).sendRequest();
};
}
}

View File

@ -0,0 +1,121 @@
<?php
/**
* CalDAV tests: create, read and delete an event
*
* @link https://www.egroupware.org
* @author Ralf Becker <rb@egroupware.org>
* @package calendar
* @subpackage tests
* @copyright (c) 2020 by Ralf Becker <rb@egroupware.org>
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
*/
namespace EGroupware\calendar;
require_once __DIR__.'/../../../api/tests/CalDAVTest.php';
use EGroupware\Api\CalDAVTest;
use GuzzleHttp\RequestOptions;
class CalDAVcreateReadDelete extends CalDAVTest
{
/**
* Test accessing CalDAV without authentication
*/
public function testNoAuth()
{
$response = $this->getClient([])->get($this->url('/'));
$this->assertHttpStatus(401, $response);
}
/**
* Test accessing CalDAV with authentication
*/
public function testAuth()
{
$response = $this->getClient()->get($this->url('/'));
$this->assertHttpStatus(200, $response);
}
const EVENT_URL = '/demo/calendar/new-event-1233456789-new.ics';
const EVENT_ICAL = <<<EOICAL
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20110406T210000
DTEND;TZID=Europe/Berlin:20110406T220000
DTSTAMP:20110406T183747Z
LAST-MODIFIED:20110406T183747Z
LOCATION:Somewhere
SUMMARY:Tonight
UID:new-event-1233456789-new
END:VEVENT
END:VCALENDAR
EOICAL;
/**
* Create an event
*/
public function testCreate()
{
$response = $this->getClient()->put($this->url(self::EVENT_URL), [
RequestOptions::HEADERS => [
'Content-Type' => 'text/calendar',
'If-None-Match' => '*',
],
RequestOptions::BODY => self::EVENT_ICAL,
]);
$this->assertHttpStatus(201, $response);
}
/**
* Read created event
*/
public function testRead()
{
$response = $this->getClient()->get($this->url(self::EVENT_URL));
$this->assertHttpStatus(200, $response);
$this->assertIcal(self::EVENT_ICAL, $response->getBody());
}
/**
* Delete created event
*/
public function testDelete()
{
$response = $this->getClient()->delete($this->url(self::EVENT_URL));
$this->assertHttpStatus(204, $response);
}
/**
* Read created event
*/
public function testReadDeleted()
{
$response = $this->getClient()->get($this->url(self::EVENT_URL));
$this->assertHttpStatus(404, $response);
}
}

View File

@ -0,0 +1,368 @@
<?php
/**
* CalDAV tests: DELETE requests for non-series by Outlook CalDAV Synchronizer and other clients
*
* @link https://www.egroupware.org
* @author Ralf Becker <rb@egroupware.org>
* @package calendar
* @subpackage tests
* @copyright (c) 2020 by Ralf Becker <rb@egroupware.org>
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
*/
namespace EGroupware\calendar;
require_once __DIR__.'/../../../api/tests/CalDAVTest.php';
use EGroupware\Api\CalDAVTest;
use GuzzleHttp\RequestOptions;
use EGroupware\Api\Acl;
/**
* Class CalDAVsingleDELETE
*
* This tests check all sorts of DELETE requests by organizer and attendees, with and without (delete) rights on the organizer.
*
* For CalDAV Synchronizer, which does not distingues between deleting and rejecting events, we only allow the
* organizer to delete an event.
*
* @package EGroupware\calendar
* @covers \calendar_groupdav::delete()
* @uses \calendar_groupdav::put()
* @uses \calendar_groupdav::get()
*/
class CalDAVsingleDELETE extends CalDAVTest
{
/**
* Users and their ACL for the test
*
* @var array
*/
protected static $users = [
'boss' => [],
'secretary' => [
'rights' => [
'boss' => Acl::READ|Acl::ADD|Acl::EDIT|Acl::DELETE,
]
],
'other' => [],
];
/**
* Create some users incl. ACL
*/
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();
self::createUsersACL(self::$users, 'calendar');
}
/**
* Check created users
*/
public function testPrincipals()
{
foreach(self::$users as $user => &$data)
{
$response = $this->getClient()->propfind($this->url('/principals/users/'.$user.'/'), [
RequestOptions::HEADERS => [
'Depth' => 0,
],
]);
$this->assertHttpStatus(207, $response);
}
}
const EVENT_BOSS_ATTENDEE_ORGANIZER_URL = '/other/calendar/new-event-boss-attendee-123456789-new.ics';
const EVENT_BOSS_ATTENDEE_URL = '/boss/calendar/new-event-boss-attendee-123456789-new.ics';
const EVENT_BOSS_ATTENDEE_ICAL = <<<EOICAL
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20110406T210000
DTEND;TZID=Europe/Berlin:20110406T220000
DTSTAMP:20110406T183747Z
LAST-MODIFIED:20110406T183747Z
LOCATION:Somewhere
SUMMARY:Tonight
ORGANIZER;CN="Other User":mailto:other@example.org
ATTENDEE;CN="Other User";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:other@example.org
ATTENDEE;CN="Boss User";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:boss@example.org
UID:new-event-boss-attendee-123456789-new
END:VEVENT
END:VCALENDAR
EOICAL;
/**
* Check secretary deletes in boss's calendar event he is an attendee / invited
*
* @throws \Horde_Icalendar_Exception
*/
public function testSecretaryDeletesBossAttendee()
{
// create invitation by organizer
$response = $this->getClient('other')->put($this->url(self::EVENT_BOSS_ATTENDEE_ORGANIZER_URL), [
RequestOptions::HEADERS => [
'Content-Type' => 'text/calendar',
'Prefer' => 'return=representation'
],
RequestOptions::BODY => self::EVENT_BOSS_ATTENDEE_ICAL,
]);
$this->assertHttpStatus([200,201], $response);
$this->assertIcal(self::EVENT_BOSS_ATTENDEE_ICAL, $response->getBody());
// secretrary deletes event in boss's calendar
$response = $this->getClient('secretary')->delete($this->url(self::EVENT_BOSS_ATTENDEE_URL));
$this->assertHttpStatus(204, $response, 'Secretary delete/rejects for boss');
// use organizer to check event still exists and boss rejected
$response = $this->getClient('other')->get($this->url(self::EVENT_BOSS_ATTENDEE_ORGANIZER_URL));
$this->assertHttpStatus(200, $response, 'Check event still exists after DELETE in attendee calendar');
$this->assertIcal(self::EVENT_BOSS_ATTENDEE_ICAL, $response->getBody(),
'Boss should have declined the invitation',
['vEvent' => [['ATTENDEE' => ['mailto:boss@example.org' => ['PARTSTAT' => 'DECLINED', 'RSVP' => 'FALSE']]]]]
);
// secretary tries to delete event in organizers calendar
$response = $this->getClient('secretary')->delete($this->url(self::EVENT_BOSS_ATTENDEE_ORGANIZER_URL));
$this->assertHttpStatus(403, $response, 'Secretary not allowed to delete for organizer');
// boss deletes/rejects event in his calendar
$response = $this->getClient('boss')->delete($this->url(self::EVENT_BOSS_ATTENDEE_URL));
$this->assertHttpStatus(204, $response, 'Boss deletes/rejects in his calendar');
// boss deletes/rejects event in organizers calendar
$response = $this->getClient('boss')->delete($this->url(self::EVENT_BOSS_ATTENDEE_ORGANIZER_URL));
$this->assertHttpStatus(204, $response, 'Boss deletes/rejects in organizers calendar');
// use organizer to delete event
$response = $this->getClient('other')->delete($this->url(self::EVENT_BOSS_ATTENDEE_ORGANIZER_URL));
$this->assertHttpStatus(204, $response);
// use organizer to check event deleted
$response = $this->getClient('other')->get($this->url(self::EVENT_BOSS_ATTENDEE_ORGANIZER_URL));
$this->assertHttpStatus(404, $response, "Check event deleted by organizer");
}
const EVENT_BOSS_ORGANIZER_URL = '/boss/calendar/new-event-boss-organizer-123456789-new.ics';
const EVENT_BOSS_ORGANIZER_OTHER_URL = '/other/calendar/new-event-boss-organizer-123456789-new.ics';
const EVENT_BOSS_ORGANIZER_ICAL = <<<EOICAL
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20110406T210000
DTEND;TZID=Europe/Berlin:20110406T220000
DTSTAMP:20110406T183747Z
LAST-MODIFIED:20110406T183747Z
LOCATION:Somewhere
SUMMARY:Tonight
ORGANIZER;CN="Boss User":mailto:boss@example.org
ATTENDEE;CN="Boss User";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:boss@example.org
ATTENDEE;CN="Other User";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:other@example.org
UID:new-event-boss-organizer-123456789-new
END:VEVENT
END:VCALENDAR
EOICAL;
/**
* Check secretary deletes for boss, which is organizer of event
*
* @throws \Horde_Icalendar_Exception
*/
public function testSecretaryDeletesBossOrganizer()
{
// create invitation by boss as organizer
$response = $this->getClient('boss')->put($this->url(self::EVENT_BOSS_ORGANIZER_URL), [
RequestOptions::HEADERS => [
'Content-Type' => 'text/calendar',
'Prefer' => 'return=representation'
],
RequestOptions::BODY => self::EVENT_BOSS_ORGANIZER_ICAL,
]);
$this->assertHttpStatus([200,201], $response);
$this->assertIcal(self::EVENT_BOSS_ORGANIZER_ICAL, $response->getBody());
// attendee deletes/rejects event in his calendar
$response = $this->getClient('other')->delete($this->url(self::EVENT_BOSS_ORGANIZER_OTHER_URL));
$this->assertHttpStatus(204, $response);
// secretrary deletes event in boss's calendar
$response = $this->getClient('secretary')->delete($this->url(self::EVENT_BOSS_ORGANIZER_URL));
$this->assertHttpStatus(204, $response, 'Secretary deletes for boss');
// use organizer/boss to check event deleted
$response = $this->getClient('boss')->get($this->url(self::EVENT_BOSS_ORGANIZER_URL));
$this->assertHttpStatus(404, $response, "Check event deleted by secretary");
}
/**
* Check organizer (boss) can delete event in his calendar
*
* @throws \Horde_Icalendar_Exception
*/
public function testOrganizerDeletes()
{
// create invitation by boss as organizer
$response = $this->getClient('boss')->put($this->url(self::EVENT_BOSS_ORGANIZER_URL), [
RequestOptions::HEADERS => [
'Content-Type' => 'text/calendar',
'Prefer' => 'return=representation'
],
RequestOptions::BODY => self::EVENT_BOSS_ORGANIZER_ICAL,
]);
$this->assertHttpStatus([200,201], $response);
$this->assertIcal(self::EVENT_BOSS_ORGANIZER_ICAL, $response->getBody());
// organizer deletes event in his calendar
$response = $this->getClient('boss')->delete($this->url(self::EVENT_BOSS_ORGANIZER_URL));
$this->assertHttpStatus(204, $response, 'Organizer deletes');
// use organizer/boss to check event deleted
$response = $this->getClient('boss')->get($this->url(self::EVENT_BOSS_ORGANIZER_URL));
$this->assertHttpStatus(404, $response, "Check event deleted by organizer");
// use attendee to check event deleted
$response = $this->getClient('other')->get($this->url(self::EVENT_BOSS_ORGANIZER_OTHER_URL));
$this->assertHttpStatus(404, $response, "Check event deleted by organizer");
}
const EVENT_SECRETARY_ATTENDEE_URL = '/secretary/calendar/new-event-secreatary-attendee-123456789-new.ics';
const EVENT_SECRETARY_ATTENDEE_ORGANIZER_URL = '/boss/calendar/new-event-secreatary-attendee-123456789-new.ics';
const EVENT_SECRETARY_ATTENDEE_ICAL = <<<EOICAL
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20110406T210000
DTEND;TZID=Europe/Berlin:20110406T220000
DTSTAMP:20110406T183747Z
LAST-MODIFIED:20110406T183747Z
LOCATION:Somewhere
SUMMARY:Tonight
ORGANIZER;CN="Boss User":mailto:boss@example.org
ATTENDEE;CN="Boss User";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:boss@example.org
ATTENDEE;CN="Secretary User";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:secretary@example.org
UID:new-event-secreatary-attendee-123456789-new
END:VEVENT
END:VCALENDAR
EOICAL;
/**
* Check secretary as attendee deletes event
*
* @throws \Horde_Icalendar_Exception
*/
public function testSecretaryAttendeeDeletes()
{
// create invitation by boss as organizer
$response = $this->getClient('boss')->put($this->url(self::EVENT_SECRETARY_ATTENDEE_ORGANIZER_URL), [
RequestOptions::HEADERS => [
'Content-Type' => 'text/calendar',
'Prefer' => 'return=representation'
],
RequestOptions::BODY => self::EVENT_SECRETARY_ATTENDEE_ICAL,
]);
$this->assertHttpStatus([200,201], $response);
$this->assertIcal(self::EVENT_SECRETARY_ATTENDEE_ICAL, $response->getBody());
// secretary deletes in her calendar
$response = $this->getClient('secretary')->delete($this->url(self::EVENT_SECRETARY_ATTENDEE_URL));
$this->assertHttpStatus(204, $response, 'Secretary (attendee) deletes');
// use organizer to check it's really deleted
$response = $this->getClient('boss')->get($this->url(self::EVENT_SECRETARY_ATTENDEE_ORGANIZER_URL));
$this->assertHttpStatus(404, $response, "Check event deleted by secretary");
}
/**
* Check secretary as attendee deletes event with CalDAVSynchronizer
*
* @throws \Horde_Icalendar_Exception
*/
public function testSecretaryAttendeeDeletesCalDAVSynchronizer()
{
// create invitation by boss as organizer
$response = $this->getClient('boss')->put($this->url(self::EVENT_SECRETARY_ATTENDEE_ORGANIZER_URL), [
RequestOptions::HEADERS => [
'Content-Type' => 'text/calendar',
'Prefer' => 'return=representation'
],
RequestOptions::BODY => self::EVENT_SECRETARY_ATTENDEE_ICAL,
]);
$this->assertHttpStatus([200,201], $response);
$this->assertIcal(self::EVENT_SECRETARY_ATTENDEE_ICAL, $response->getBody());
// secretary deletes in her calendar with CalDAVSynchronizer
$response = $this->getClient('secretary')->delete($this->url(self::EVENT_SECRETARY_ATTENDEE_URL),
[RequestOptions::HEADERS => ['User-Agent' => 'CalDAVSynchronizer']]);
$this->assertHttpStatus(204, $response, 'Secretary (attendee) deletes/rejects');
// use organizer to check it's NOT deleted, as CalDAVSynchronizer / Outlook does not distinguish between reject and delete
$response = $this->getClient('boss')->get($this->url(self::EVENT_SECRETARY_ATTENDEE_ORGANIZER_URL));
$this->assertHttpStatus(200, $response, "Check event NOT deleted by secretary");
$this->assertIcal(self::EVENT_SECRETARY_ATTENDEE_ICAL, $response->getBody(),
'Secretary should have declined the invitation',
['vEvent' => [['ATTENDEE' => ['mailto:secretary@example.org' => ['PARTSTAT' => 'DECLINED', 'RSVP' => 'FALSE']]]]]
);
// organizer deletes in his calendar with CalDAVSynchronizer
$response = $this->getClient('boss')->delete($this->url(self::EVENT_SECRETARY_ATTENDEE_ORGANIZER_URL),
[RequestOptions::HEADERS => ['User-Agent' => 'CalDAVSynchronizer']]);
$this->assertHttpStatus(204, $response, 'Organizer deletes');
// use organizer to check it's deleted, as CalDAVSynchronizer / Outlook should still delete for organizer
$response = $this->getClient('boss')->get($this->url(self::EVENT_SECRETARY_ATTENDEE_ORGANIZER_URL));
$this->assertHttpStatus(404, $response, "Check event deleted by organizer");
}
}

View File

@ -25,17 +25,17 @@ class ImportParticipantsTest extends \EGroupware\Api\AppTest
// Method under test with modified access
private $parse_method = null;
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();
}
public static function tearDownAfterClass()
public static function tearDownAfterClass() : void
{
parent::tearDownAfterClass();
}
public function setUp()
protected function setUp() : void
{
$this->bo = new \calendar_bo();
@ -50,7 +50,7 @@ class ImportParticipantsTest extends \EGroupware\Api\AppTest
$this->parse_method->setAccessible(true);
}
public function tearDown()
protected function tearDown() : void
{
}

View File

@ -24,17 +24,17 @@ class ResetParticipantStatusTest extends \EGroupware\Api\AppTest
// Method under test with modified access
private $check_method = null;
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();
}
public static function tearDownAfterClass()
public static function tearDownAfterClass() : void
{
parent::tearDownAfterClass();
}
public function setUp()
protected function setUp() : void
{
$this->bo = new \calendar_boupdate();
@ -47,7 +47,7 @@ class ResetParticipantStatusTest extends \EGroupware\Api\AppTest
$this->check_method->setAccessible(true);
}
public function tearDown()
protected function tearDown() : void
{
// Clean up user

View File

@ -28,20 +28,20 @@ class TimezoneTest extends \EGroupware\Api\AppTest {
protected $recur_end;
protected $cal_id;
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();
static::$server_tz = date_default_timezone_get();
}
public static function tearDownAfterClass()
public static function tearDownAfterClass() : void
{
date_default_timezone_set(static::$server_tz);
parent::tearDownAfterClass();
}
public function setUp()
protected function setUp() : void
{
$this->bo = new \calendar_boupdate();
@ -50,7 +50,7 @@ class TimezoneTest extends \EGroupware\Api\AppTest {
$this->recur_end = new Api\DateTime(mktime(0,0,0,date('m'), date('d') + static::RECUR_DAYS, date('Y')));
}
public function tearDown()
protected function tearDown() : void
{
$this->bo->delete($this->cal_id);
// Delete again to remove from delete history

View File

@ -45,12 +45,12 @@
],
"config": {
"platform": {
"php": "7.0"
"php": "7.2"
},
"sort-packages": true
},
"require": {
"php": ">=7.0,<=8.0.0alpha1",
"php": ">=7.2,<=8.0.0alpha1",
"ext-gd": "*",
"ext-json": "*",
"ext-mysqli": "*",
@ -101,8 +101,10 @@
"tinymce/tinymce": "^5.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^6.5",
"phpunit/phpunit": "~8"
},
"suggests": {
"suggest": {
"ext-opcache": "Opcode cache to speed up PHP",
"ext-apcu": "Used for in-memory caching",
"ext-tidy": "Used for tidying up docx templates",

1854
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,9 @@ The provided docker-compose.yml will run the following container:
* **egroupware-nginx** running Nginx as webserver (by default http only on port 8080)
* **egroupware-db** latest MariaDB 10.4
* **egroupware-watchtower** updating all above container automatically daily at 4am
* **collabora-key** Collabora Online Office
* **rocketchat** Rocket.Chat server
* **rocketchat-mongodb** MongoDB for Rocket.Chat
```
version: '3'
volumes:
@ -36,6 +39,29 @@ volumes:
# # location of deprecated EGroupware packages like Wiki, SiteMgr, KnowledgeBase
# device: /usr/share/egroupware
# #device: $PWD/extra
# collabora-config
collabora-config:
driver_opts:
type: none
o: bind
# to upgrade an existing non-docker installation most easy is to use the existing
# data directory /var/lib/egroupware AND the host database see below
#device: /var/lib/egroupware/default/loolwsd
# otherwise data is stored in data subdirectory of the current directory
device: $PWD/data/default/loolwsd
# store Rocket.Chat MongoDB on an (internal) Volume
mongo:
# directory to store MongoDB dumps
rocketchat-dumps:
driver_opts:
type: none
o: bind
device: $PWD/data/default/rocketchat/dump
rocketchat-uploads:
driver_opts:
type: none
o: bind
device: $PWD/data/default/rocketchat/uploads
services:
egroupware:
image: egroupware/egroupware:latest
@ -52,13 +78,18 @@ services:
# 1. comment out the whole db service below AND
# 2. set EGW_DB_HOST=localhost AND
# 3. uncomment the next line and modify the host path (first one), it depends on your distro:
# - RHEL/CentOS /var/lib/mysql/mysql.sock
# - openSUSE/SLE /var/run/mysql/mysql.sock
# - Debian/Ubuntu /var/run/mysqld/mysqld.sock
#- /var/run/mysqld/mysqld.sock:/var/run/mysqld/mysqld.sock
# - RHEL/CentOS /var/lib/mysql/mysql.sock:/var/run/mysqld/mysqld.sock
# - openSUSE/SLE /var/run/mysql/mysql.sock:/var/run/mysqld/mysqld.sock
# - Debian/Ubuntu /var/run/mysqld:/var/run/mysqld
#- /var/run/mysqld:/var/run/mysqld
# private CA so egroupware can validate your certificate to talk to Collabora or Rocket.Chat
# multiple certificates (eg. a chain) have to be single files in a directory, with one named private-ca.crt!
#- /etc/egroupware-docker/private-ca.crt:/usr/local/share/ca-certificates/private-ca.crt:ro
environment:
# MariaDB/MySQL host to use: for internal service use "db", for host database (socket bind-mounted into container) use "localhost"
- EGW_DB_HOST=db
# grant host is needed for NOT using localhost / unix domain socket for MySQL/MariaDB
- EGW_DB_GRANT_HOST=172.%
# for internal db service you should to specify a root password here AND in db service
# a database "egroupware" with a random password is created for you on installation (password is stored in header.inc.php in data directory)
#- EGW_DB_ROOT=root
@ -133,4 +164,66 @@ services:
command: --schedule "0 0 4 * * *"
container_name: egroupware-watchtower
restart: always
# Collabora Online Office
collabora-key:
image: "quay.io/egroupware/collabora-key:stable"
#image: collabora/code:latest
# needs to be initialised via: docker run --rm -v dev_collabora-config:/mnt --entrypoint '/bin/cp -r /etc/loolwsd /mnt' quay.io/egroupware/collabora-key:stable
volumes:
- collabora-config:/etc/loolwsd
# dont try to regenerate the (not used certificate) as volumn is readonly
environment:
- DONT_GEN_SSL_CERT=1
restart: always
container_name: collabora-key
# set the ip-address of your docker host AND your official DNS name so Collabora
# can access EGroupware without the need to go over your firewall
#extra_hosts:
#- "my.host.name:ip-address"
# Rocket.Chat server
rocketchat:
image: rocketchat/rocket.chat:latest
command: bash -c 'for i in `seq 1 30`; do node main.js && s=$$? && break || s=$$?; echo "Tried $$i times. Waiting 5 secs..."; sleep 5; done; (exit $$s)'
restart: unless-stopped
volumes:
- rocketchat-uploads:/app/uploads
# if EGroupware uses a certificate from a private CA, OAuth authentication will fail, you need to:
# - have the CA certificate stored at /etc/egroupware-docker/private-ca.crt
# - uncomment the next 2 lines about the private CA:
# - /etc/egroupware-docker/private-ca.crt:/usr/local/share/ca-certificates/private-ca.crt:ro
environment:
# - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/private-ca.crt
# IMPORTANT: change ROOT_URL to your actual url eg. https://domain.com/rocketchat
- ROOT_URL=http://localhost/rocketchat
- PORT=3000
- MONGO_URL=mongodb://mongo:27017/rocketchat
- MONGO_OPLOG_URL=mongodb://mongo:27017/local
# - HTTP_PROXY=http://proxy.domain.com
# - HTTPS_PROXY=http://proxy.domain.com
depends_on:
- mongo
container_name: rocketchat
# set the ip-address of your docker host AND your official DNS name so Rocket.Chat
# can access EGroupware without the need to go over your firewall
#extra_hosts:
#- "my.host.name:ip-address"
# MongoDB for Rocket.Chat
mongo:
image: mongo:4.0
restart: unless-stopped
volumes:
- mongo:/data/db
- rocketchat-dumps:/dump
command: mongod --smallfiles --oplogSize 128 --replSet rs0 --storageEngine=mmapv1
container_name: rocketchat-mongo
# this container's job is just run the command to initialize the replica set.
# it will run the command and remove himself (it will not stay running)
mongo-init-replica:
image: mongo:4.0
command: 'bash -c "for i in `seq 1 30`; do mongo mongo/rocketchat --eval \"rs.initiate({ _id: ''rs0'', members: [ { _id: 0, host: ''localhost:27017'' } ]})\" && s=$$? && break || s=$$?; echo \"Tried $$i times. Waiting 5 secs...\"; sleep 5; done; (exit $$s)"'
depends_on:
- mongo
```

View File

@ -7,6 +7,10 @@ The container and docker-compose.yml file in this directory are the most easy wa
* data: EGroupware stores it's files here, by default $PWD/data subdirectory, can also be your existing /var/lib/egroupware
* db: volume for MariaDB (should be NOT a directory under Mac OS and Windows for performance reasons!)
* sessions: volume for sessions, internal no need to change
* sources-push: swoolpush sub-directory of sources
* collabora-config: /etc/loolwsd for Collabora container, by default $PWD/data/default/loolwsd
* rocketchat-uploads: Upload directory for Rocket.Chat, by default $PWD/data/default/rocketchat/uploads
* rocketchat-dumps: Dump directory for MongoDB, by default $PWD/data/default/rocketchat/dump
### It runs the following containers:
* egroupware: php-fpm
@ -14,11 +18,9 @@ The container and docker-compose.yml file in this directory are the most easy wa
* egroupware-db: MariaDB
* egroupware-push: PHP Swoole based push server
* egroupware-watchtower: to automatic keeps the containers up to date
Planned, but not yet there:
* egroupware-collabora: Collabora Online Office
* egroupware-rocketchat: Rocket.Chat
* egroupware-mongo: MongoDB for Rocket.Chat
* collabora: Collabora Online Office
* rocketchat: Rocket.Chat
* rocketchat-mongo: MongoDB for Rocket.Chat
### Usage:
```

View File

@ -40,6 +40,20 @@ volumes:
sessions:
# cache files from compose, npm and yarn (actually /root inside the container)
cache:
# store Rocket.Chat MongoDB on an (internal) Volume
mongo:
# directory to store MongoDB dumps
rocketchat-dumps:
driver_opts:
type: none
o: bind
device: $PWD/data/default/rocketchat/dump
rocketchat-uploads:
driver_opts:
type: none
o: bind
device: $PWD/data/default/rocketchat/uploads
services:
egroupware:
# you can also use tags like: 7.3, 7.3.12 or 7.4
@ -56,10 +70,13 @@ services:
# 1. comment out the whole db service below AND
# 2. set EGW_DB_HOST=localhost AND
# 3. uncomment the next line and modify the host path (first one), it depends on your distro:
# - RHEL/CentOS /var/lib/mysql/mysql.sock
# - openSUSE/SLE /var/run/mysql/mysql.sock
# - Debian/Ubuntu /var/run/mysqld/mysqld.sock
#- /var/run/mysqld/mysqld.sock:/var/run/mysqld/mysqld.sock
# - RHEL/CentOS /var/lib/mysql/mysql.sock:/var/run/mysqld/mysqld.sock
# - openSUSE/SLE /var/run/mysql/mysql.sock:/var/run/mysqld/mysqld.sock
# - Debian/Ubuntu /var/run/mysqld:/var/run/mysqld
#- /var/run/mysqld:/var/run/mysqld
# private CA so egroupware can validate your certificate to talk to Collabora or Rocket.Chat
# multiple certificates (eg. a chain) have to be single files in a directory, with one named private-ca.crt!
#- /etc/egroupware-docker/private-ca.crt:/usr/local/share/ca-certificates/private-ca.crt:ro
environment:
#
# MariaDB/MySQL host to use: for internal service use "db", for host database (socket bind-mounted into container) use "localhost"
@ -122,6 +139,7 @@ services:
depends_on:
- egroupware
- collabora-key
- rocketchat
container_name: egroupware-nginx
# run an own MariaDB:10.4 (you can use EGroupware's database backup and restore to add your existing database)
@ -185,3 +203,48 @@ services:
# can access EGroupware without the need to go over your firewall
#extra_hosts:
#- "my.host.name:ip-address"
# Rocket.Chat server
rocketchat:
image: rocketchat/rocket.chat:latest
command: bash -c 'for i in `seq 1 30`; do node main.js && s=$$? && break || s=$$?; echo "Tried $$i times. Waiting 5 secs..."; sleep 5; done; (exit $$s)'
restart: unless-stopped
volumes:
- rocketchat-uploads:/app/uploads
# if EGroupware uses a certificate from a private CA, OAuth authentication will fail, you need to:
# - have the CA certificate stored at /etc/egroupware-docker/private-ca.crt
# - uncomment the next 2 lines about the private CA:
# - /etc/egroupware-docker/private-ca.crt:/usr/local/share/ca-certificates/private-ca.crt:ro
environment:
# - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/private-ca.crt
# IMPORTANT: change ROOT_URL to your actual url eg. https://domain.com/rocketchat
- ROOT_URL=http://localhost/rocketchat
- PORT=3000
- MONGO_URL=mongodb://mongo:27017/rocketchat
- MONGO_OPLOG_URL=mongodb://mongo:27017/local
# - HTTP_PROXY=http://proxy.domain.com
# - HTTPS_PROXY=http://proxy.domain.com
depends_on:
- mongo
container_name: rocketchat
# set the ip-address of your docker host AND your official DNS name so Rocket.Chat
# can access EGroupware without the need to go over your firewall
#extra_hosts:
#- "my.host.name:ip-address"
# MongoDB for Rocket.Chat
mongo:
image: mongo:4.0
restart: unless-stopped
volumes:
- mongo:/data/db
- rocketchat-dumps:/dump
command: mongod --smallfiles --oplogSize 128 --replSet rs0 --storageEngine=mmapv1
container_name: rocketchat-mongo
# this container's job is just run the command to initialize the replica set.
# it will run the command and remove himself (it will not stay running)
mongo-init-replica:
image: mongo:4.0
command: 'bash -c "for i in `seq 1 30`; do mongo mongo/rocketchat --eval \"rs.initiate({ _id: ''rs0'', members: [ { _id: 0, host: ''localhost:27017'' } ]})\" && s=$$? && break || s=$$?; echo \"Tried $$i times. Waiting 5 secs...\"; sleep 5; done; (exit $$s)"'
depends_on:
- mongo

View File

@ -42,8 +42,8 @@ server {
fastcgi_read_timeout 60m;
fastcgi_index index.php;
fastcgi_split_path_info ^((?U).+\.php)(.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
# standard Nginx
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/egroupware$1;
@ -80,9 +80,9 @@ server {
fastcgi_read_timeout 60m;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
# ActiveSync support
@ -92,8 +92,8 @@ server {
fastcgi_read_timeout 60m;
fastcgi_index index.php;
fastcgi_split_path_info ^((?U).+\.php)(.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/egroupware/activesync/index.php;
}
@ -110,25 +110,38 @@ server {
location = / {
return 301 $scheme://$http_host/egroupware/index.php;
}
# redirect /egroupware to /egroupware/
location = /egroupware {
return 301 $scheme://$host/egroupware/index.php;
}
# Collabora sniplet meant to be included in server block of EGroupware vhost
# static files
location ^~ /loleaflet {
proxy_pass http://collabora-key:9980;
proxy_set_header Host $http_host;
}
# static files
location ^~ /loleaflet {
proxy_pass http://collabora-key:9980;
proxy_set_header Host $http_host;
}
# WOPI discovery URL
location ^~ /hosting/discovery {
proxy_pass http://collabora-key:9980;
proxy_set_header Host $http_host;
}
# WOPI discovery URL
location ^~ /hosting/discovery {
proxy_pass http://collabora-key:9980;
proxy_set_header Host $http_host;
}
# websockets, download, presentation and image upload
location ^~ /lool {
proxy_pass http://collabora-key:9980;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
}
# websockets, download, presentation and image upload
location ^~ /lool {
proxy_pass http://collabora-key:9980;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
}
# Rocket.Chat sniplet meant to be included in server block of EGroupware vhost
# proxy into rocketchat container
location /rocketchat {
proxy_pass http://rocketchat:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
}
}

View File

@ -19,6 +19,29 @@ volumes:
# # location of deprecated EGroupware packages like Wiki, SiteMgr, KnowledgeBase
# device: /usr/share/egroupware
# #device: $PWD/extra
# collabora-config
collabora-config:
driver_opts:
type: none
o: bind
# to upgrade an existing non-docker installation most easy is to use the existing
# data directory /var/lib/egroupware AND the host database see below
#device: /var/lib/egroupware/default/loolwsd
# otherwise data is stored in data subdirectory of the current directory
device: $PWD/data/default/loolwsd
# store Rocket.Chat MongoDB on an (internal) Volume
mongo:
# directory to store MongoDB dumps
rocketchat-dumps:
driver_opts:
type: none
o: bind
device: $PWD/data/default/rocketchat/dump
rocketchat-uploads:
driver_opts:
type: none
o: bind
device: $PWD/data/default/rocketchat/uploads
services:
egroupware:
image: egroupware/egroupware:latest
@ -35,10 +58,13 @@ services:
# 1. comment out the whole db service below AND
# 2. set EGW_DB_HOST=localhost AND
# 3. uncomment the next line and modify the host path (first one), it depends on your distro:
# - RHEL/CentOS /var/lib/mysql/mysql.sock
# - openSUSE/SLE /var/run/mysql/mysql.sock
# - Debian/Ubuntu /var/run/mysqld/mysqld.sock
#- /var/run/mysqld/mysqld.sock:/var/run/mysqld/mysqld.sock
# - RHEL/CentOS /var/lib/mysql/mysql.sock:/var/run/mysqld/mysqld.sock
# - openSUSE/SLE /var/run/mysql/mysql.sock:/var/run/mysqld/mysqld.sock
# - Debian/Ubuntu /var/run/mysqld:/var/run/mysqld
#- /var/run/mysqld:/var/run/mysqld
# private CA so egroupware can validate your certificate to talk to Collabora or Rocket.Chat
# multiple certificates (eg. a chain) have to be single files in a directory, with one named private-ca.crt!
#- /etc/egroupware-docker/private-ca.crt:/usr/local/share/ca-certificates/private-ca.crt:ro
environment:
# MariaDB/MySQL host to use: for internal service use "db", for host database (socket bind-mounted into container) use "localhost"
- EGW_DB_HOST=db
@ -83,6 +109,8 @@ services:
- "4443:443"
depends_on:
- egroupware
- collabora-key
- rocketchat
container_name: egroupware-nginx
# run an own MariaDB:10.4 (you can use EGroupware's database backup and restore to add your existing database)
@ -118,3 +146,65 @@ services:
command: --schedule "0 0 4 * * *"
container_name: egroupware-watchtower
restart: always
# Collabora Online Office
collabora-key:
image: "quay.io/egroupware/collabora-key:stable"
#image: collabora/code:latest
# needs to be initialised via: docker run --rm -v dev_collabora-config:/mnt --entrypoint '/bin/cp -r /etc/loolwsd /mnt' quay.io/egroupware/collabora-key:stable
volumes:
- collabora-config:/etc/loolwsd
# dont try to regenerate the (not used certificate) as volumn is readonly
environment:
- DONT_GEN_SSL_CERT=1
restart: always
container_name: collabora-key
# set the ip-address of your docker host AND your official DNS name so Collabora
# can access EGroupware without the need to go over your firewall
#extra_hosts:
#- "my.host.name:ip-address"
# Rocket.Chat server
rocketchat:
image: rocketchat/rocket.chat:latest
command: bash -c 'for i in `seq 1 30`; do node main.js && s=$$? && break || s=$$?; echo "Tried $$i times. Waiting 5 secs..."; sleep 5; done; (exit $$s)'
restart: unless-stopped
volumes:
- rocketchat-uploads:/app/uploads
# if EGroupware uses a certificate from a private CA, OAuth authentication will fail, you need to:
# - have the CA certificate stored at /etc/egroupware-docker/private-ca.crt
# - uncomment the next 2 lines about the private CA:
# - /etc/egroupware-docker/private-ca.crt:/usr/local/share/ca-certificates/private-ca.crt:ro
environment:
# - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/private-ca.crt
# IMPORTANT: change ROOT_URL to your actual url eg. https://domain.com/rocketchat
- ROOT_URL=http://localhost/rocketchat
- PORT=3000
- MONGO_URL=mongodb://mongo:27017/rocketchat
- MONGO_OPLOG_URL=mongodb://mongo:27017/local
# - HTTP_PROXY=http://proxy.domain.com
# - HTTPS_PROXY=http://proxy.domain.com
depends_on:
- mongo
container_name: rocketchat
# set the ip-address of your docker host AND your official DNS name so Rocket.Chat
# can access EGroupware without the need to go over your firewall
#extra_hosts:
#- "my.host.name:ip-address"
# MongoDB for Rocket.Chat
mongo:
image: mongo:4.0
restart: unless-stopped
volumes:
- mongo:/data/db
- rocketchat-dumps:/dump
command: mongod --smallfiles --oplogSize 128 --replSet rs0 --storageEngine=mmapv1
container_name: rocketchat-mongo
# this container's job is just run the command to initialize the replica set.
# it will run the command and remove himself (it will not stay running)
mongo-init-replica:
image: mongo:4.0
command: 'bash -c "for i in `seq 1 30`; do mongo mongo/rocketchat --eval \"rs.initiate({ _id: ''rs0'', members: [ { _id: 0, host: ''localhost:27017'' } ]})\" && s=$$? && break || s=$$?; echo \"Tried $$i times. Waiting 5 secs...\"; sleep 5; done; (exit $$s)"'
depends_on:
- mongo

View File

@ -1,8 +1,16 @@
#!/bin/bash
set -e
# if EGW_APC_SHM_SIZE is set in environment, propagate value to apcu.ini
test -n "$EGW_APC_SHM_SIZE" && {
grep "apc.shm_size" /etc/php/7.3/fpm/conf.d/20-apcu.ini >/dev/null && \
sed -e "s/^;\?apc.shm_size.*/apc.shm_size=$EGW_APC_SHM_SIZE/g" \
-i /etc/php/7.3/fpm/conf.d/20-apcu.ini || \
echo "apc.shm_size=$EGW_APC_SHM_SIZE" >> /etc/php/7.3/fpm/conf.d/20-apcu.ini \
}
# if EGW_SESSION_TIMEOUT is set in environment, propagate value to php.ini
test -n "$EGW_SESSION_TIMEOUT" && test "$EGW_SESSION_TIMEOUT" -ge 1440 &&
test -n "$EGW_SESSION_TIMEOUT" && test "$EGW_SESSION_TIMEOUT" -ge 1440 && \
sed -e "s/^;\?session.gc_maxlifetime.*/session.gc_maxlifetime=$EGW_SESSION_TIMEOUT/g" \
-i /etc/php/7.3/fpm/php.ini

View File

@ -121,4 +121,38 @@ server {
location = / {
return 301 $redirectscheme://$host/egroupware/index.php;
}
# redirect /egroupware to /egroupware/
location = /egroupware {
return 301 $redirectscheme://$host/egroupware/index.php;
}
# Collabora sniplet meant to be included in server block of EGroupware vhost
# static files
location ^~ /loleaflet {
proxy_pass http://collabora-key:9980;
proxy_set_header Host $http_host;
}
# WOPI discovery URL
location ^~ /hosting/discovery {
proxy_pass http://collabora-key:9980;
proxy_set_header Host $http_host;
}
# websockets, download, presentation and image upload
location ^~ /lool {
proxy_pass http://collabora-key:9980;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
}
# Rocket.Chat sniplet meant to be included in server block of EGroupware vhost
# proxy into rocketchat container
location /rocketchat {
proxy_pass http://rocketchat:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
}
}

View File

@ -24,6 +24,17 @@ if (!class_exists('\PHPUnit\Framework\TestCase') && class_exists('\PHPUnit_Frame
// Needed to let Cache work
$GLOBALS['egw_info']['server']['temp_dir'] = '/tmp';
$GLOBALS['egw_info']['server']['install_id'] = 'PHPUnit test';
// setting a working session.save_path
if (ini_get('session.save_handler') === 'files' && !is_writable(ini_get('session.save_path')) &&
is_dir('/tmp') && is_writable('/tmp'))
{
ini_set('session.save_path','/tmp'); // regular users may have no rights to apache's session dir
}
// set domain from doc/phpunit.xml
if (!isset($_SERVER['HTTP_HOST']) && $GLOBALS['EGW_DOMAIN'] !== 'default')
{
$_SERVER['HTTP_HOST'] = $GLOBALS['EGW_DOMAIN'];
}
// Symlink api/src/fixtures/apps/* to root
foreach(scandir($path=__DIR__.'/../api/tests/fixtures/apps') as $app)

27
doc/travis-ci-apache.conf Normal file
View File

@ -0,0 +1,27 @@
<VirtualHost *:80>
# https://docs.travis-ci.com/user/languages/php/#apache--php
DocumentRoot %TRAVIS_BUILD_DIR%
# tests assume EGroupware to be under /egroupware not docroot
Alias /egroupware %TRAVIS_BUILD_DIR%
<Directory "%TRAVIS_BUILD_DIR%/">
Options FollowSymLinks MultiViews ExecCGI
AllowOverride All
Require all granted
</Directory>
# Wire up Apache to use Travis CI's php-fpm.
<IfModule mod_fastcgi.c>
AddHandler php7-fcgi .php
Action php7-fcgi /php7-fcgi
Alias /php7-fcgi /usr/lib/cgi-bin/php7-fcgi
FastCgiExternalServer /usr/lib/cgi-bin/php7-fcgi -host 127.0.0.1:9000 -pass-header Authorization
<Directory /usr/lib/cgi-bin>
Require all granted
</Directory>
</IfModule>
</VirtualHost>

View File

@ -11,11 +11,10 @@
*/
use EGroupware\Api;
use EGroupware\Api\Link;
use EGroupware\Api\Framework;
use EGroupware\Api\Egw;
use EGroupware\Api\Etemplate;
use EGroupware\Api\Framework;
use EGroupware\Api\Link;
use EGroupware\Api\Vfs;
/**
@ -1023,6 +1022,10 @@ class filemanager_ui
{
$vfs_options['mime'] = $query['col_filter']['mime'];
}
if($namefilter)
{
$vfs_options['name'] = $query['search'];
}
return $vfs_options;
}

View File

@ -28,7 +28,7 @@ class ContactTest extends \EGroupware\Api\AppTest
// Infolog under test
protected $info_id = null;
public function setUp()
protected function setUp() : void
{
$this->ui = new \infolog_ui();
@ -39,7 +39,7 @@ class ContactTest extends \EGroupware\Api\AppTest
$this->mockTracking($this->bo, 'infolog_tracking');
}
public function tearDown()
protected function tearDown() : void
{
// Double delete to make sure it's gone, not preserved due to history setting
if($this->info_id)

View File

@ -34,7 +34,7 @@ class SetProjectManagerTest extends \EGroupware\Api\AppTest
protected $pm_id = null;
public function setUp()
protected function setUp() : void
{
$this->ui = new \infolog_ui();
@ -63,7 +63,7 @@ class SetProjectManagerTest extends \EGroupware\Api\AppTest
$this->makeProject();
}
public function tearDown()
protected function tearDown() : void
{
// Remove infolog under test
if($this->info_id)

View File

@ -37,7 +37,7 @@ class StatusTest extends \EGroupware\Api\AppTest
/**
* Create a custom status we can use to test
*/
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();
@ -47,7 +47,7 @@ class StatusTest extends \EGroupware\Api\AppTest
Api\Config::save_value('status',$bo->status,'infolog');
}
public static function tearDownAfterClass()
public static function tearDownAfterClass() : void
{
// Remove custom status
$bo = new \infolog_bo();
@ -58,14 +58,14 @@ class StatusTest extends \EGroupware\Api\AppTest
parent::tearDownAfterClass();
}
public function setUp()
protected function setUp() : void
{
$this->bo = new \infolog_bo();
$this->mockTracking($this->bo, 'infolog_tracking');
}
public function tearDown()
protected function tearDown() : void
{
$this->bo = null;
}

View File

@ -142,7 +142,7 @@ else
$passwd_type = $_POST['passwd_type'];
// forced password change
if($GLOBALS['egw']->session->cd_reason != Api\Session::CD_FORCE_PASSWORD_CHANGE)
if($GLOBALS['egw']->session->cd_reason == Api\Session::CD_FORCE_PASSWORD_CHANGE)
{
// no automatic login
}

View File

@ -446,6 +446,9 @@ div.mail-compose_fileselector {
position: relative;
background-color: white;
}
div#mail-index_mailPreview > div {
padding-left: 8px !important;
}
#mail-index_mailPreview .et2_email>span{
display: inline;
}

View File

@ -72,10 +72,10 @@ tr.mail.labelone td:first-child {
border-left: 6px solid #ff0080 !important;
}
tr.mail.labeltwo td:first-child {
border-left: 6px solid #ff8000 !important;
border-left: 6px solid #ff8000 !important;
}
tr.mail.labelthree td:first-child {
border-left: 6px solid #008000 !important;
border-left: 6px solid #008000 !important;
}
tr.mail.labelfour td:first-child {
border-left: 6px solid #0000ff !important;
@ -456,6 +456,9 @@ div.mail-compose_fileselector {
position: relative;
background-color: white;
}
div#mail-index_mailPreview > div {
padding-left: 8px !important;
}
#mail-index_mailPreview .et2_email > span {
display: inline;
}
@ -469,7 +472,7 @@ div.mail-compose_fileselector {
border: none;
}
#mail-compose_composeToolbar > button {
padding: .2em .4em;
padding: 0.2em 0.4em;
}
#mail-compose_composeToolbar > img {
width: 16px;
@ -482,7 +485,7 @@ div.mail-compose_fileselector {
height: 16px !important;
}
#mail-compose_composeToolbar > button {
padding: .2em .4em;
padding: 0.2em 0.4em;
}
#mail-compose_to div.ms-sel-ctn,
#mail-compose_cc .ms-sel-ctn,
@ -490,7 +493,7 @@ div.mail-compose_fileselector {
max-height: 75px;
}
#mail-display_toolbar > button > span {
padding: .2em .4em;
padding: 0.2em 0.4em;
}
#mail-display_toolbar > button > span > img {
width: 16px;
@ -513,7 +516,7 @@ div.mail-compose_fileselector {
height: 35px;
}
#mail-index_toolbar > button > span {
padding: .2em .4em;
padding: 0.2em 0.4em;
}
#mail-index_toolbar > button > span > img {
width: 16px;
@ -1649,11 +1652,11 @@ tr.mail.deleted td:first-child {
}
span.status_img {
display: inline-block;
width: 12px;
height: 12px;
width: 16px;
height: 16px;
background-repeat: no-repeat;
background-image: url(../pixelegg/images/kmmsgread.svg);
background-size: 12px 12px;
background-size: 16px 16px;
}
tr.deleted span.status_img {
background-image: url(../pixelegg/images/kmmsgdel.svg);
@ -1668,7 +1671,7 @@ tr.flagged_unseen span.status_img {
background-image: url(../pixelegg/images/unread_flagged_small.svg) !important;
}
tr.recent span.status_img {
background-image: url(../pixelegg/images/kmmsgnew.png) !important;
background-image: url(../pixelegg/images/kmmsgnew.svg) !important;
}
tr.replied span.status_img {
background-image: url(../pixelegg/images/mail_reply.svg) !important;
@ -1890,7 +1893,7 @@ input[type=button] {
padding: 0px;
}
#mail-display_toolbar > button > span {
padding: .2em .4em;
padding: 0.2em 0.4em;
}
#mail-display_toolbar > button > span > img {
width: 16px;

View File

@ -444,6 +444,9 @@ div.mail-compose_fileselector {
position: relative;
background-color: white;
}
div#mail-index_mailPreview > div {
padding-left: 8px !important;
}
#mail-index_mailPreview .et2_email > span {
display: inline;
}

View File

@ -18,23 +18,23 @@ class SaveToVfsTest extends \PHPUnit\Framework\TestCase
/**
* Create a custom status we can use to test
*/
public static function setUpBeforeClass()
public static function setUpBeforeClass() : void
{
parent::setUpBeforeClass();
}
public static function tearDownAfterClass()
public static function tearDownAfterClass() : void
{
// Have to remove custom status first, before the DB is gone
parent::tearDownAfterClass();
}
public function setUp()
protected function setUp() : void
{
}
public function tearDown()
protected function tearDown() : void
{
}

View File

@ -1809,7 +1809,6 @@ div#loginMainDiv.stockLoginBackground div#centerBox form {
background: white;
opacity: 0.94;
margin-left: 20px;
border-radius: 5px;
margin-right: 20px;
padding: 20px;
margin-top: 20px;
@ -1888,7 +1887,6 @@ div#loginMainDiv.stockLoginBackground div#centerBox form {
width: 280px;
}
#loginMainDiv div#centerBox form {
border-radius: 5px;
opacity: 0.94;
background-color: white;
padding: 1em;

View File

@ -96,7 +96,6 @@ div#loginMainDiv.stockLoginBackground {
background: white;
opacity: 0.94;
margin-left: 20px;
border-radius: 5px;
margin-right: 20px;
padding: 20px;
margin-top: 20px;
@ -184,7 +183,6 @@ div#loginMainDiv.stockLoginBackground {
// Formular
form {
border-radius:5px;
opacity:0.94;
background-color: white;
padding:1em;

View File

@ -253,6 +253,7 @@ class setup
case PHP_SESSION_DISABLED:
throw new \ErrorException('EGroupware requires PHP session extension!');
case PHP_SESSION_NONE:
if (headers_sent()) return true;
ini_set('session.use_cookie', true);
session_name(self::SESSIONID);
session_set_cookie_params(0, '/', self::cookiedomain(),

View File

@ -25,9 +25,9 @@ $GLOBALS['egw_info'] = array(
'currentapp' => 'setup',
'noapi' => True
));
if(file_exists('../header.inc.php'))
if(file_exists(__DIR__.'/../../header.inc.php'))
{
include('../header.inc.php');
include_once(__DIR__.'/../../header.inc.php');
}
// for an old header we need to setup a reference for the domains
if (!is_array($GLOBALS['egw_domain'])) $GLOBALS['egw_domain'] =& $GLOBALS['phpgw_domain'];