summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorctucx <c@ctu.cx>2020-02-11 17:13:37 +0100
committerctucx <c@ctu.cx>2020-02-11 17:13:37 +0100
commit97f3070fbae6baba92bfe22b065f2a6796fd3e52 (patch)
treec198460528998e21ac611d8a4326cd7f54367cee
downloadtinyDAV-97f3070fbae6baba92bfe22b065f2a6796fd3e52.tar.gz
tinyDAV-97f3070fbae6baba92bfe22b065f2a6796fd3e52.tar.bz2
tinyDAV-97f3070fbae6baba92bfe22b065f2a6796fd3e52.zip
init
-rw-r--r--docker-compose.yml15
-rw-r--r--docker/Dockerfile60
-rw-r--r--docker/entrypoint.sh38
-rw-r--r--docker/infcloud-config.js993
-rw-r--r--docker/nginx.conf46
-rw-r--r--docker/php-settings.ini5
-rw-r--r--public/composer.json7
-rw-r--r--public/composer.lock510
-rw-r--r--public/dav.php75
-rw-r--r--public/index.php333
-rw-r--r--public/install.php24
-rw-r--r--public/lib/AddressbookCollectionManager.php47
-rw-r--r--public/lib/CalendarCollectionManager.php78
-rw-r--r--public/lib/Database.php112
-rw-r--r--public/lib/GenericCollectionManager.php237
-rw-r--r--public/lib/Helpers.php40
-rw-r--r--public/lib/JSONDB.php446
-rw-r--r--public/lib/Router.php104
-rw-r--r--public/lib/Sabre/FilesPlugin.php121
-rw-r--r--public/lib/Sabre/SabreAuthenticationJsonBackend.php34
-rw-r--r--public/lib/Sabre/SabreCalDAVJsonBackend.php367
-rw-r--r--public/lib/Sabre/SabreCardDAVJsonBackend.php127
-rw-r--r--public/lib/Sabre/SabrePrincipalJsonBackend.php201
-rw-r--r--public/lib/Sabre/SabrePropertyStorageJsonBackend.php194
-rw-r--r--public/lib/Template.php161
-rw-r--r--public/lib/UserManager.php141
-rw-r--r--public/main.css6
-rw-r--r--public/milligram.min.css11
-rw-r--r--public/template/footer.tpl6
-rw-r--r--public/template/header.tpl16
-rw-r--r--public/template/login.tpl17
-rw-r--r--public/template/message.tpl7
-rw-r--r--public/template/overview.tpl142
-rw-r--r--public/template/update.tpl138
34 files changed, 4859 insertions, 0 deletions
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2980dd8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,15 @@
+version: "3"
+
+services:
+ tinydav:
+ build:
+ context: ./
+ dockerfile: ./docker/Dockerfile
+ image: tinydav
+ container_name: ctucx_tinydav
+ user: "1000:1000"
+ restart: always
+ volumes:
+ - ./data:/app/data
+ ports:
+ - "8088:8080" \ No newline at end of file
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000..913c4a6
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,60 @@
+FROM php:7.4-fpm-alpine
+
+ARG UID=1000
+ARG GID=1000
+
+ARG NGINX_VER=1.17.6
+ARG NGINX_CONF="--prefix=/app --with-cc-opt='-static' \
+ --with-ld-opt='-static' --with-cpu-opt=generic --with-pcre \
+ --sbin-path=/app/nginx \
+ --http-log-path=/app/log/access.log \
+ --error-log-path=/app/log/error.log \
+ --pid-path=/app/nginx.pid \
+ --lock-path=/app/nginx.lock \
+ --without-http_gzip_module \
+ --without-http_uwsgi_module \
+ --without-http_scgi_module \
+ --without-http_memcached_module \
+ --without-http_empty_gif_module \
+ --without-http_geo_module \
+ --with-threads"
+
+WORKDIR /tmp
+
+COPY ./public /app/www
+COPY ./docker/php-settings.ini $PHP_INI_DIR/conf.d/settings.ini
+COPY ./docker/entrypoint.sh /app/entrypoint.sh
+COPY ./docker/nginx.conf /app/nginx.conf
+
+RUN apk --update upgrade && \
+ apk add --no-cache --no-progress build-base pcre-dev wget libxml2 libxml2-dev && \
+ wget http://nginx.org/download/nginx-${NGINX_VER}.tar.gz && \
+ wget https://www.inf-it.com/InfCloud_0.13.1.zip && \
+ wget https://getcomposer.org/download/1.9.3/composer.phar -O /tmp/composer.phar && \
+ tar xzf nginx-${NGINX_VER}.tar.gz && \
+ unzip InfCloud_0.13.1.zip && \
+ chmod +x /tmp/composer.phar && \
+ cd /tmp/nginx-${NGINX_VER} && \
+ ./configure ${NGINX_CONF} && \
+ make -j 1 && \
+ make install && \
+ docker-php-ext-install simplexml dom && \
+ apk del --no-cache --no-progress build-base pcre-dev wget libxml2-dev && \
+ mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" && \
+ cd /app/www && /tmp/composer.phar install && \
+ mkdir /app/tmp && \
+ mkdir /app/data && \
+ mkdir /app/logs && \
+ cp -r /tmp/infcloud /app/www/infcloud && \
+ chown -R ${UID}:${GID} /app && \
+ chmod +x /app/entrypoint.sh && \
+ chmod 7777 /app/tmp && \
+ rm -rf /tmp/nginx-${NGINX_VER} /tmp/nginx-${NGINX_VER}.tar.gz /tmp/composer.phar /tmp/InfCloud_0.13.1.zip /tmp/infcloud
+
+COPY ./docker/infcloud-config.js /app/www/infcloud/config.js
+
+EXPOSE 8080
+
+VOLUME [ "/app/data" ]
+
+ENTRYPOINT ["/app/entrypoint.sh"]
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 0000000..af4d0a8
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,38 @@
+#!/bin/sh
+
+php /app/www/install.php
+
+# Start the first process
+/app/nginx -c /app/nginx.conf
+status=$?
+if [ $status -ne 0 ]; then
+ echo "Failed to start my_first_process: $status"
+ exit $status
+fi
+
+# Start the second process
+php-fpm
+status=$?
+if [ $status -ne 0 ]; then
+ echo "Failed to start my_second_process: $status"
+ exit $status
+fi
+
+# Naive check runs checks once a minute to see if either of the processes exited.
+# This illustrates part of the heavy lifting you need to do if you want to run
+# more than one service in a container. The container exits with an error
+# if it detects that either of the processes has exited.
+# Otherwise it loops forever, waking up every 60 seconds
+
+while sleep 60; do
+ ps aux |grep my_first_process |grep -q -v grep
+ PROCESS_1_STATUS=$?
+ ps aux |grep my_second_process |grep -q -v grep
+ PROCESS_2_STATUS=$?
+ # If the greps above find anything, they exit with 0 status
+ # If they are not both 0, then something is wrong
+ if [ $PROCESS_1_STATUS -ne 0 -o $PROCESS_2_STATUS -ne 0 ]; then
+ echo "One of the processes has already exited."
+ exit 1
+ fi
+done
diff --git a/docker/infcloud-config.js b/docker/infcloud-config.js
new file mode 100644
index 0000000..1338e39
--- /dev/null
+++ b/docker/infcloud-config.js
@@ -0,0 +1,993 @@
+var globalNetworkCheckSettings={
+ href: 'https://dav.ctu.cx/dav/principals/',
+ timeOut: 90000,
+ lockTimeOut: 10000,
+ checkContentType: true,
+ settingsAccount: true,
+ delegation: false,
+ additionalResources: [],
+ hrefLabel: null,
+ forceReadOnly: null,
+ ignoreAlarms: false,
+ backgroundCalendars: []
+}
+
+var globalBackgroundSync=true;
+var globalSyncResourcesInterval=120000;
+var globalEnableRefresh=true;
+var globalEnableKbNavigation=true;
+
+
+// globalSettingsType
+// Where to store user settings such as: active view, enabled/selected
+// collections, ... (the client store them into DAV property on the server).
+// NOTE: not all servers support storing DAV properties (some servers support
+// only subset /or none/ of these URLs).
+// Supported values:
+// - 'principal-URL', '', null or undefined (default) => settings are stored
+// to principal-URL (recommended for most servers)
+// - 'addressbook-home-set' => settings are are stored to addressbook-home-set
+// - 'calendar-home-set' => settings are stored to calendar-home-set
+// Example:
+//var globalSettingsType='';
+
+
+// globalCrossServerSettingsURL
+// Settings such as enabled/selected collections are stored on the server
+// (see the previous option) in form of full URL
+// (e.g.: https://user@server:port/principal/collection/), but even if this
+// approach is "correct" (you can use the same principal URL with multiple
+// different logins, ...) it causes a problem if your server is accessible
+// from multiple URLs (e.g. http://server/ and https://server/). If you want
+// to store only the "principal/collection/" part of the URL (instead of the
+// full URL) then enable this option.
+// Example:
+//var globalCrossServerSettingsURL=false;
+
+
+var globalInterfaceLanguage='de_DE';
+
+
+var globalInterfaceCustomLanguages=['en_US', 'de_DE'];
+
+var globalSortAlphabet=' 0123456789'+
+ 'AÀÁÂÄÆÃÅĀBCÇĆČDĎEÈÉÊËĒĖĘĚFGĞHIÌÍÎİÏĪĮJKLŁĹĽMNŃÑŇOÒÓÔÖŐŒØÕŌ'+
+ 'PQRŔŘSŚŠȘșŞşẞTŤȚțŢţUÙÚÛÜŰŮŪVWXYÝŸZŹŻŽ'+
+ 'aàáâäæãåābcçćčdďeèéêëēėęěfgğhiìíîïīįıjklłĺľmnńñňoòóôöőœøõō'+
+ 'pqrŕřsśšßtťuùúûüűůūvwxyýÿzźżžАБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЮЯ'+
+ 'Ьабвгґдеєжзиіїйклмнопрстуфхцчшщюяь';
+
+var globalSearchTransformAlphabet={
+ '[ÀàÁáÂâÄäÆæÃãÅåĀā]': 'a', '[ÇçĆćČč]': 'c', '[Ďď]': 'd',
+ '[ÈèÉéÊêËëĒēĖėĘęĚě]': 'e', '[Ğğ]': 'g', '[ÌìÍíÎîİıÏïĪīĮį]': 'i',
+ '[ŁłĹ弾]': 'l', '[ŃńÑñŇň]': 'n', '[ÒòÓóÔôÖöŐőŒœØøÕõŌō]': 'o',
+ '[ŔŕŘř]': 'r', '[ŚśŠšȘșŞşẞß]': 's', '[ŤťȚțŢţ]': 't',
+ '[ÙùÚúÛûÜüŰűŮůŪū]': 'u', '[ÝýŸÿ]': 'y', '[ŹźŻżŽž]': 'z'
+};
+
+// globalResourceAlphabetSorting
+// If more than one resource (server account) is configured, sort the
+// resources alphabetically?
+// Example:
+var globalResourceAlphabetSorting=true;
+
+
+// globalNewVersionNotifyUsers
+// Update notification will be shown only to users with login names defined
+// in this array.
+// If undefined (or empty), update notifications will be shown to all users.
+// Example:
+// globalNewVersionNotifyUsers=['admin', 'peter'];
+var globalNewVersionNotifyUsers=[];
+
+
+// globalDatepickerFormat
+// Set the datepicker format (see
+// http://docs.jquery.com/UI/Datepicker/formatDate for valid values).
+// NOTE: date format is predefined for each localization - use this option
+// ONLY if you want to use custom date format (instead of the localization
+// predefined one).
+// Example:
+//var globalDatepickerFormat='dd.mm.yy';
+
+
+// globalDatepickerFirstDayOfWeek
+// Set the datepicker first day of the week: Sunday is 0, Monday is 1, etc.
+// Example:
+var globalDatepickerFirstDayOfWeek=1;
+
+
+// globalHideInfoMessageAfter
+// How long are information messages (such as: success, error) displayed
+// (in miliseconds).
+// Example:
+var globalHideInfoMessageAfter=1800;
+
+
+// globalEditorFadeAnimation
+// Set the editor fade in/out animation duration when editing or saving data
+// (in miliseconds).
+// Example:
+var globalEditorFadeAnimation=666;
+
+
+
+
+// ******* CalDAV (CalDavZAP) related settings ******* //
+
+// globalEventStartPastLimit, globalEventStartFutureLimit, globalTodoPastLimit
+// Number of months pre-loaded from past/future in advance for calendars
+// and todo lists (if null then date range synchronization is disabled).
+// NOTE: interval synchronization is used only if your server supports
+// sync-collection REPORT (e.g. DAViCal).
+// NOTE: if you experience problems with data loading and your server has
+// no time-range filtering support set these variables to null.
+// Example:
+var globalEventStartPastLimit=3;
+var globalEventStartFutureLimit=3;
+var globalTodoPastLimit=1;
+
+
+// globalLoadedCalendarCollections
+// This option sets the list of calendar collections (down)loaded after login.
+// If empty then all calendar collections for the currently logged user are
+// loaded.
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+var globalLoadedCalendarCollections=[];
+
+
+// globalLoadedTodoCollections
+// This option sets the list of todo collections (down)loaded after login.
+// If empty then all todo collections for the currently logged user are loaded.
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+var globalLoadedTodoCollections=[];
+
+
+// globalActiveCalendarCollections
+// This options sets the list of calendar collections checked (enabled
+// checkbox => data visible in the interface) by default after login.
+// If empty then all loaded calendar collections for the currently logged
+// user are checked.
+// NOTE: only already (down)loaded collections can be checked (see
+// the globalLoadedCalendarCollections option).
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+var globalActiveCalendarCollections=[];
+
+
+// globalActiveTodoCollections
+// This options sets the list of todo collections checked (enabled
+// checkbox => data visible in the interface) by default after login.
+// If empty then all loaded todo collections for the currently logged
+// user are checked.
+// NOTE: only already (down)loaded collections can be checked (see
+// the globalLoadedTodoCollections option).
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+var globalActiveTodoCollections=[];
+
+
+// globalCalendarSelected
+// This option sets which calendar collection will be pre-selected
+// (if you create a new event) by default after login.
+// The value must be URL encoded path to a calendar collection,
+// for example: 'USER/calendar/'
+// If empty or undefined then the first available calendar collection
+// is selected automatically.
+// NOTE: only already (down)loaded collections can be pre-selected (see
+// the globalLoadedCalendarCollections option).
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+//var globalCalendarSelected='';
+
+
+// globalTodoCalendarSelected
+// This option sets which todo collection will be pre-selected
+// (if you create a new todo) by default after login.
+// The value must be URL encoded path to a todo collection,
+// for example: 'USER/todo_calendar/'
+// If empty or undefined then the first available todo collection
+// is selected automatically.
+// NOTE: only already (down)loaded collections can be pre-selected (see
+// the globalLoadedTodoCollections option).
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+//var globalTodoCalendarSelected='';
+
+
+// globalActiveView
+// This options sets the default fullcalendar view option (the default calendar
+// view after the first login).
+// Supported values:
+// - 'month'
+// - 'multiWeek'
+// - 'agendaWeek'
+// - 'agendaDay'
+// NOTE: we use custom and enhanced version of fullcalendar!
+// Example:
+var globalActiveView='multiWeek';
+
+
+// globalOpenFormMode
+// Open new event form on 'single' or 'double' click.
+// If undefined or not 'double', then 'single' is used.
+// Example:
+var globalOpenFormMode='double';
+
+
+// globalTodoListFilterSelected
+// This options sets the list of filters in todo list that are selected
+// after login.
+// Supported options:
+// - 'filterAction'
+// - 'filterProgress' (available only if globalAppleRemindersMode is disabled)
+// - 'filterCompleted'
+// - 'filterCanceled' (available only if globalAppleRemindersMode is disabled)
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+var globalTodoListFilterSelected=['filterAction', 'filterProgress'];
+
+
+// globalCalendarStartOfBusiness, globalCalendarEndOfBusiness
+// These options set the start and end of business hours with 0.5 hour
+// precision. Non-business hours are faded out in the calendar interface.
+// If both variables are set to the same value then no fade out occurs.
+// Example:
+var globalCalendarStartOfBusiness=8;
+var globalCalendarEndOfBusiness=17;
+
+
+// globalDefaultEventDuration
+// This option sets the default duration (in minutes) for newly created events.
+// If undefined or null, globalCalendarEndOfBusiness value will be taken as
+// a default end time instead.
+// Example:
+var globalDefaultEventDuration=120;
+
+
+// globalAMPMFormat
+// This option enables to use 12 hours format (AM/PM) for displaying time.
+// NOTE: time format is predefined for each localization - use this option
+// ONLY if you want to use custom time format (instead of the localization
+// predefined one).
+// Example:
+//var globalAMPMFormat=false;
+
+
+// globalTimeFormatBasic
+// This option defines the time format information for events in month and
+// multiweek views. If undefined or null then default value is used.
+// If defined as empty string no time information is shown in these views.
+// See http://arshaw.com/fullcalendar/docs/utilities/formatDate/ for exact
+// formating rules.
+// Example:
+//var globalTimeFormatBasic='';
+
+
+// globalTimeFormatAgenda
+// This option defines the time format information for events in day and
+// week views. If undefined or null then default value is used.
+// If defined as empty string no time information is shown in these views.
+// See http://arshaw.com/fullcalendar/docs/utilities/formatDate/ for exact
+// formating rules.
+// Example:
+//var globalTimeFormatAgenda='';
+
+
+// globalDisplayHiddenEvents
+// This option defined whether events from unechecked calendars are displayed
+// with certain transparency (true) or completely hidden (false).
+// Example:
+var globalDisplayHiddenEvents=false;
+
+
+// globalTimeZoneSupport
+// This option enables timezone support in the client.
+// NOTE: timezone cannot be specified for all-day events because these don't
+// have start and end time.
+// If this option is disabled then local time is used.
+// Example:
+var globalTimeZoneSupport=true;
+
+
+// globalTimeZone
+// If timezone support is enabled, this option sets the default timezone.
+// See timezones.js or use the following command to get the list of supported
+// timezones (defined in timezones.js):
+// grep "'[^']\+': {" timezones.js | sed -Ee "s#(\s*'|':\s*\{)##g"
+// Example:
+var globalTimeZone='Europe/Berlin';
+
+
+// globalTimeZonesEnabled
+// This option sets the list of available timezones in the interface (for the
+// list of supported timezones see the comment for the previous configuration
+// option).
+// NOTE: if there is at least one event/todo with a certain timezone defined,
+// that timezone is enabled (even if it is not present in this list).
+// Example:
+// var globalTimeZonesEnabled=['America/New_York', 'Europe/Berlin'];
+var globalTimeZonesEnabled=[];
+
+
+// globalRewriteTimezoneComponent
+// This options sets whether the client will enhance/replace (if you edit an
+// event or todo) the timezone information using the official IANA timezone
+// database information (recommended).
+// Example:
+var globalRewriteTimezoneComponent=true;
+
+
+// globalRemoveUnknownTimezone
+// This options sets whether the client will remove all non-standard timezone
+// names from events and todos (if you edit an event or todo)
+// (e.g.: /freeassociation.sourceforge.net/Tzfile/Europe/Vienna)
+// Example:
+var globalRemoveUnknownTimezone=false;
+
+
+// globalShowHiddenAlarms
+// This option sets whether the client will show alarm notifications for
+// unchecked calendars. If this option is enabled and you uncheck a calendar
+// in the calendar list, alarm notifications will be temporary disabled for
+// unchecked calendar(s).
+// Example:
+var globalShowHiddenAlarms=false;
+
+
+// globalIgnoreCompletedOrCancelledAlarms
+// This options sets whether the client will show alarm notifications for
+// already completed or cancelled todos. If enabled then alarm notification
+// for completed and cancelled todos are disabled.
+// Example:
+var globalIgnoreCompletedOrCancelledAlarms=true;
+
+
+// globalMozillaSupport
+// Mozilla automatically treats custom repeating event calculations as if
+// the start day of the week is Monday, despite what day is chosen in settings.
+// Set this variable to true to use the same approach, ensuring compatible
+// event rendering in special cases.
+// Example:
+var globalMozillaSupport=false;
+
+
+// globalCalendarColorPropertyXmlns
+// This options sets the namespace used for storing the "calendar-color"
+// property by the client.
+// If true, undefined (or empty) "http://apple.com/ns/ical/" is used (Apple
+// compatible). If false, then the calendar color modification functionality
+// is completely disabled.
+// Example:
+//var globalCalendarColorPropertyXmlns=true;
+
+
+// globalWeekendDays
+// This option sets the list of days considered as weekend days (these
+// are faded out in the calendar interface). Non-weekend days are automatically
+// considered as business days.
+// Sunday is 0, Monday is 1, etc.
+// Example:
+var globalWeekendDays=[0, 6];
+
+
+// globalAppleRemindersMode
+// If this option is enabled then then client will use the same approach
+// for handling repeating reminders (todos) as Apple. It is STRONGLY
+// recommended to enabled this option if you use any Apple clients for
+// reminders (todos).
+// Supported options:
+// - 'iOS6'
+// - 'iOS7'
+// - true (support of the latest iOS version - 'iOS8')
+// - false
+// If this option is enabled:
+// - RFC todo support is SEVERELY limited and the client mimics the behaviour
+// of Apple Reminders.app (to ensure maximum compatibility)
+// - when a single instance of repeating todo is edited, it becomes an
+// autonomous non-repeating todo with NO relation to the original repeating
+// todo
+// - capabilities of repeating todos are limited - only the first instance
+// is ever visible in the interface
+// - support for todo DTSTART attribute is disabled
+// - support for todo STATUS attribute other than COMPLETED and NEEDS-ACTION
+// is disabled
+// - [iOS6 only] support for LOCATION and URL attributes is disabled
+// Example:
+var globalAppleRemindersMode=true;
+
+
+// globalSubscribedCalendars
+// This option specifies a list of remote URLs to ics files (e.g.: used
+// for distributing holidays information). Subscribed calendars are
+// ALWAYS read-only. Remote servers where ics files are hosted MUST
+// return proper CORS headers (see readme.txt) otherwise this functionality
+// will not work!
+// NOTE: subsribed calendars are NOT "shared" calendars. For "shared"
+// calendars see the delegation option in globalAccountSettings,
+// globalNetworkCheckSettings and globalNetworkAccountSettings.
+// List of properties used in globalSubscribedCalendars variable:
+// - hrefLabel
+// This options defines the header string above the subcsribed calendars.
+// - calendars
+// This option specifies an array of remote calendar objects with the
+// following properties:
+// - href
+// Set this option to the "full URL" of the remote calendar
+// - userAuth
+// NOTE: keep empty if remote authentication is not required!
+// - userName
+// Set the username you want to login.
+// - userPassword
+// Set the password for the given username.
+// - typeList
+// Set the list of objects you want to process from remote calendars;
+// two options are available:
+// - 'vevent' (show remote events in the interface)
+// - 'vtodo' (show remote todos in the interface)
+// - ignoreAlarm
+// Set this option to true if you want to disable alarm notifications
+// from the remote calendar.
+// - displayName
+// Set this option to the name of the calendar you want to see
+// in the interface.
+// - color
+// Set the calendar color you want to see in the interface.
+// Example:
+//var globalSubscribedCalendars={
+// hrefLabel: 'Subscribed',
+// calendars: [
+// {
+// href: 'http://something.com/calendar.ics',
+// userAuth: {
+// userName: '',
+// userPassword: ''
+// },
+// typeList: ['vevent', 'vtodo'],
+// ignoreAlarm: true,
+// displayName: 'Remote Calendar 1',
+// color: '#ff0000'
+// },
+// {
+// href: 'http://calendar.com/calendar2.ics',
+// ...
+// ...
+// }
+// ]
+//};
+
+
+
+// ******* CardDAV (CardDavMATE) related settings ******* //
+
+
+// globalLoadedAddressbookCollections
+// This option sets the list of addressbook collections (down)loaded after
+// login. If empty then all addressbook collections for the currently logged
+// user are loaded.
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+var globalLoadedAddressbookCollections=[];
+
+
+// globalActiveAddressbookCollections
+// This options sets the list of addressbook collections checked (enabled
+// checkbox => data visible in the interface) by default after login.
+// If empty then all loaded addressbook collections for the currently logged
+// user are checked.
+// NOTE: only already (down)loaded collections can be checked (see
+// the globalLoadedAddressbookCollections option).
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+var globalActiveAddressbookCollections=[];
+
+
+// globalAddressbookSelected
+// This option sets which addressbook collection will be pre-selected
+// (if you create a new contact) by default after login.
+// The value must be URL encoded path to an addressbook collection,
+// for example: 'USER/addressbook/'
+// If empty or undefined then the first available addressbook collection
+// is selected automatically.
+// NOTE: only already (down)loaded collections can be pre-selected (see
+// the globalLoadedAddressbookCollections option).
+// NOTE: settings stored on the server (see settingsAccount) overwrite this
+// option.
+// Example:
+//var globalAddressbookSelected='';
+
+
+// globalCompatibility
+// This options is reserved for various compatibility settings.
+// NOTE: if this option is used the value must be an object.
+// Currently there is only one supported option:
+// - anniversaryOutputFormat
+// Different clients use different (and incompatible) approach
+// to store anniversary date in vCards. Apple stores this attribute as:
+// itemX.X-ABDATE;TYPE=pref:2000-01-01\r\n
+// itemX.X-ABLabel:_$!<Anniversary>!$_\r\n'
+// other clients store this attribute as:
+// X-ANNIVERSARY:2000-01-01\r\n
+// Choose 'apple' or 'other' (lower case) for your 3rd party client
+// compatibility. You can chose both: ['apple', 'other'], but it may
+// cause many problems in the future, for example: duplicate anniversary
+// dates, invalid/old anniversary date in your clients, ...)
+// Examples:
+// anniversaryOutputFormat: ['other']
+// anniversaryOutputFormat: ['apple', 'other']
+// Example:
+var globalCompatibility={anniversaryOutputFormat: ['apple']};
+
+
+// globalUriHandler{Tel,Email,Url,Profile}
+// These options set the URI handlers for TEL, EMAIL, URL and X-SOCIALPROFILE
+// vCard attributes. Set them to null (or comment out) to disable.
+// NOTE: for globalUriHandlerTel is recommended to use 'tel:', 'callto:'
+// or 'skype:'. The globalUriHandlerUrl value is used only if no URI handler
+// is defined in the URL.
+// NOTE: it is safe to keep these values unchanged!
+// Example:
+var globalUriHandlerTel='tel:';
+var globalUriHandlerEmail='mailto:';
+var globalUriHandlerUrl='http://';
+var globalUriHandlerProfile={
+ 'twitter': 'http://twitter.com/%u',
+ 'facebook': 'http://www.facebook.com/%u',
+ 'flickr': 'http://www.flickr.com/photos/%u',
+ 'linkedin': 'http://www.linkedin.com/in/%u',
+ 'myspace': 'http://www.myspace.com/%u',
+ 'sinaweibo': 'http://weibo.com/n/%u'
+};
+
+
+// globalDefaultAddressCountry
+// This option sets the default country for new address fields.
+// See common.js or use the following command to get the list of
+// all supported country codes (defined in common.js):
+// grep -E "'[a-z]{2}':\s+\[" common.js | sed -Ee 's#^\s+|\s+\[\s+# #g'
+// Example:
+var globalDefaultAddressCountry='us';
+
+
+// globalAddressCountryEquivalence
+// This option sets the processing of the country field specified
+// in the vCard ADR attribute.
+// By default the address field in vCard looks like:
+// ADR;TYPE=WORK:;;1 Waters Edge;Baytown;LA;30314;USA\r\n
+// what cause a problem, because the country field is a plain
+// text and can contain any value, e.g.:
+// USA
+// United States of America
+// US
+// and because the address format can be completely different for
+// each country, e.g.:
+// China address example:
+// [China]
+// [Province] [City]
+// [Street]
+// [Postal]
+// Japan address example:
+// [Postal]
+// [Prefecture] [County/City]
+// [Further Divisions]
+// [Japan]
+// the client needs to correctly detect the country from the ADR
+// attribute. Apple solved this problem by using:
+// item1.ADR;TYPE=WORK:;;1 Waters Edge;Baytown;LA;30314;USA\r\n
+// item1.X-ABADR:us\r\n
+// where the second "related" attribute defines the country code
+// for the ADR attribute. This client uses the same approach, but
+// if the vCard is created by 3rd party clients and the X-ABADR
+// is missing, it is possible to define additional "rules" for
+// country matching. These rules are specied by the country code
+// (for full list of country codes see the comment for pre previous
+// option) and a case insensitive regular expression (which matches
+// the plain text value in the country field).
+// NOTE: if X-ABADR is not present and the country not matches any
+// country defined in this option, then globalDefaultAddressCountry
+// is used by default.
+// Example:
+var globalAddressCountryEquivalence=[
+ {country: 'de', regex: '^\\W*Deutschland\\W*$'},
+ {country: 'sk', regex: '^\\W*Slovensko\\W*$'}
+];
+
+
+// globalAddressCountryFavorites
+// This option defines the list of countries which are shown at the top
+// of the country list in the interface (for full list of country codes
+// see the comment for pre globalDefaultAddressCountry option).
+// Example:
+// var globalAddressCountryFavorites=['de','sk'];
+var globalAddressCountryFavorites=[];
+
+
+// globalAddrColorPropertyXmlns
+// This options sets the namespace used for storing the "addressbook-color"
+// property by the client.
+// If true, undefined (or empty) "http://inf-it.com/ns/ab/" is used.
+// If false, then the addressbook color modification functionality
+// is completely disabled, and addressbook colors in the interface are
+// generated automatically.
+// Example:
+//var globalAddrColorPropertyXmlns=true;
+
+
+// globalContactStoreFN
+// This option specifies how the FN (formatted name) is stored into vCard.
+// The value for this options must be an array of strings, that can contain
+// the following variables:
+// prefix
+// last
+// middle
+// first
+// suffix
+// The string element of the array can contain any other characters (usually
+// space or colon). Elements are added into FN only if the there is
+// a variable match, for example if:
+// last='Lastname'
+// first='Firstname'
+// middle='' (empty)
+// and this option is set to:
+// ['last', ' middle', ' first'] (space in the second and third element)
+// the resulting value for FN will be: 'Lastname Firstname' and not
+// 'Lastname Firstname' (two spaces), because the middle name is empty (so
+// the second element is completely ignored /not added into FN/).
+// NOTE: this attribute is NOT used by this client, and it is also NOT
+// possible to directly edit it in the interface.
+// Examples:
+// var globalContactStoreFN=[' last', ' middle', ' first'];
+// var globalContactStoreFN=['last', ', middle', ' ,first'];
+var globalContactStoreFN=['prefix',' last',' middle',' first',' suffix'];
+
+
+// globalGroupContactsByCompanies
+// This options specifies how contacts are grouped in the interface.
+// By default the interface looks like (very simple example):
+// A
+// Adams Adam
+// Anderson Peter
+// B
+// Brown John
+// Baker Josh
+// if grouped by company/deparment the result is:
+// Company A [Department X]
+// Adams Adam
+// Brown John
+// Company B [Department Y]
+// Anderson Peter
+// Baker Josh
+// If this option is set to true contacts are grouped by company/department,
+// otherwise (default) contacts are grouped by letters of the alphabet.
+// If undefined or not true, grouping by alphabet letters is used.
+// NOTE: see also the globalCollectionDisplay option below.
+var globalGroupContactsByCompanies=false;
+
+
+// globalCollectionDisplay
+// This options specifies how data columns in the contact list are displayed.
+//
+// NOTE: columns are displayed ONLY if there is enought horizontal place in
+// the browser window (e.g. if you define 5 columns here, but your browser
+// window is not wide enough, you will see only first 3 columns instead of 5).
+//
+// NOTE: see the globalContactDataMinVisiblePercentage option which defines the
+// width for columns.
+//
+// The value must be an array of columns, where each column is represented by
+// an object with the following properties:
+// label => the value of this option is a string used as column header
+// You can use the following localized variables in the label string:
+// - {Name}
+// - {FirstName}
+// - {LastName}
+// - {MiddleName}
+// - {NickName}
+// - {Prefix}
+// - {Suffix}
+// - {BirthDay}
+// - {PhoneticLastName}
+// - {PhoneticFirstName}
+// - {JobTitle}
+// - {Company}
+// - {Department}
+// - {Categories}
+// - {NoteText}
+// - {Address}, {AddressWork}, {AddressHome}, {AddressOther}
+// - {Phone}, {PhoneWork}, {PhoneHome}, {PhoneCell}, {PhoneMain},
+// {PhonePager}, {PhoneFax}, {PhoneIphone}, {PhoneOther}
+// - {Email}, {EmailWork}, {EmailHome}, {EmailMobileme}, {EmailOther}
+// - {URL}, {URLWork}, {URLHome}, {URLHomepage}, {URLOther}
+// - {Dates}, {DatesAnniversary}, {DatesOther}
+// - {Related}, {RelatedManager}, {RelatedAssistant}, {RelatedFather},
+// {RelatedMother}, {RelatedParent}, {RelatedBrother}, {RelatedSister},
+// {RelatedChild}, {RelatedFriend}, {RelatedSpouse}, {RelatedPartner},
+// {RelatedOther}
+// - {Profile}, {ProfileTwitter}, {ProfileFacebook}, {ProfileFlickr},
+// {ProfileLinkedin}, {ProfileMyspace}, {ProfileSinaweibo}
+// - {IM}, {IMWork}, {IMHome}, {IMMobileme}, {IMOther}, {IMAim}, {IMIcq},
+// {IMIrc}, {IMJabber}, {IMMsn}, {IMYahoo}, {IMFacebook}, {IMGadugadu},
+// {IMGoogletalk}, {IMQq}, {IMSkype}
+// value => the value of this option is an array of format strings, or
+// an object with the following properties:
+// - company (used for company contacts)
+// - personal (used for user contacts)
+// where the value of these properties is an array of format strings used
+// for company or user contacts (you can have different values in the same
+// column for personal and company contacts).
+// You can use the following simple variables in the format string:
+// - {FirstName}
+// - {LastName}
+// - {MiddleName}
+// - {NickName}
+// - {Prefix}
+// - {Suffix}
+// - {BirthDay}
+// - {PhoneticLastName}
+// - {PhoneticFirstName}
+// - {JobTitle}
+// - {Company}
+// - {Department}
+// - {Categories}
+// - {NoteText}
+// You can also use parametrized variables, where the parameter is enclosed
+// in square bracket. Paramatrized variables are useful to extract data
+// such as home phone {Phone[type=home]}, extract the second phone number
+// {Phone[:1]} (zero based indexing) or extract the third home phone number
+// {Phone[type=home][:2]} from the vCard.
+// NOTE: if the parametrized variable matches multiple items, e.g.:
+// {Phone[type=work]} (if the contact has multiple work phones) then the
+// first one is used!
+//
+// The following parametrized variables are supported (note: you can use
+// all of them also without parameters /the first one will be used/):
+// - {Address[type=XXX]} or {Address[:NUM]} or {Address[type=XXX][:NUM]}
+// where supported values for XXX are:
+// - work
+// - home
+// - other
+// - any other custom value
+// - {Phone[type=XXX]} or {Phone[:NUM]} or {Phone[type=XXX][:NUM]}
+// where supported values for XXX are:
+// - work
+// - home
+// - cell
+// - main
+// - pager
+// - fax
+// - iphone
+// - other
+// - any other custom value
+// - {Email[type=XXX]} or {Email[:NUM]} or {Email[type=XXX][:NUM]}
+// where supported values for XXX are:
+// - work
+// - home
+// - mobileme
+// - other
+// - any other custom value
+// - {URL[type=XXX]} or {URL[:NUM]} or {URL[type=XXX][:NUM]}
+// where supported values for XXX are:
+// - work
+// - home
+// - homepage
+// - other
+// - any other custom value
+// - {Dates[type=XXX]} or {Dates[:NUM]} or {Dates[type=XXX][:NUM]}
+// where supported values for XXX are:
+// - anniversary
+// - other
+// - any other custom value
+// - {Related[type=XXX]} or {Related[:NUM]} or {Related[type=XXX][:NUM]}
+// where supported values for XXX are:
+// - manager
+// - assistant
+// - father
+// - mother
+// - parent
+// - brother
+// - sister
+// - child
+// - friend
+// - spouse
+// - partner
+// - other
+// - any other custom value
+// - {Profile[type=XXX]} or {Profile[:NUM]} or {Profile[type=XXX][:NUM]}
+// where supported values for XXX are:
+// - twitter
+// - facebook
+// - flickr
+// - linkedin
+// - myspace
+// - sinaweibo
+// - any other custom value
+// - {IM[type=XXX]} or {IM[service-type=YYY]} or {IM[:NUM]}
+// where supported values for XXX are:
+// - work
+// - home
+// - mobileme
+// - other
+// - any other custom value
+// and supported values for YYY are:
+// - aim
+// - icq
+// - irc
+// - jabber
+// - msn
+// - yahoo
+// - facebook
+// - gadugadu
+// - googletalk
+// - qq
+// - skype
+// - any other custom value
+//
+// NOTE: if you want to use the "any other custom value" option (for XXX
+// or YYY above) you MUST double escape the following characters:
+// =[]{}\
+// for example:
+// - for profile type "=XXX=" use: '{Profile[type=\\=XXX\\=]}'
+// - for profile type "\XXX\" use: '{Profile[type=\\\\XXX\\\\]}'
+//
+// NOTE: if you want to use curly brackets in the format string you must
+// double escape it, e.g.: ['{Company}', '\\{{Department}\\}']
+//
+// The format string (for the value option) is an array to allow full
+// customization of the interface. For example if:
+// value: ['{LastName} {MiddleName} {FirstName}']
+// and the person has no middle name, then the result in the column
+// will be (without quotes):
+// "Parker Peter" (note: two space characters)
+// but if you use:
+// value: ['{LastName}', ' {MiddleName}', ' {FirstName}']
+// then the result will be (without quotes):
+// "Parker Peter" (note: only one space character)
+// The reason is that only those elements of the array are appended
+// into the result where non-empty substitution was performed (so the
+// ' {MiddleName}' element in this case is ignored, because the person
+// in the example above has no /more precisely has empty/ middle name).
+//
+// Examples:
+// To specify two columns (named "Company" and "Department / LastName"),
+// where the first will display the company name, and the second will display
+// department for company contacts (with "Dep -" prefix), and lastname for
+// personal contacts (with "Name -" prefix) use:
+// var globalCollectionDisplay=[
+// {
+// label: 'Company',
+// value: ['{Company}']
+// },
+// {
+// label: 'Department / LastName',
+// value: {
+// company: ['Dep - {Department}'],
+// personal: ['Name - {LastName}']
+// }
+// }
+// ];
+// To specify 3 columns (named "Categories", "URL" and "IM"), where the first
+// will display categories, second will display the third work URL, and third
+// will display ICQ IM use:
+// var globalCollectionDisplay=[
+// {
+// label: 'Categories',
+// value: ['{Categories}']
+// },
+// {
+// label: 'URL',
+// value: ['{URL[type=WORK][:2]}']
+// },
+// {
+// label: 'IM',
+// value: ['{IM[service-type=ICQ]}']
+// }
+// ];
+//
+// Recommended settings if globalGroupContactsByCompanies
+// is set to false:
+// var globalCollectionDisplay=[
+// {
+// label: '{Name}',
+// value: ['{LastName}', ' {MiddleName}', ' {FirstName}']
+// },
+// {
+// label: '{Company} [{Department}]',
+// value: ['{Company}', ' [{Department}]']
+// },
+// {
+// label: '{JobTitle}',
+// value: ['{JobTitle}']
+// },
+// {
+// label: '{Email}',
+// value: ['{Email[:0]}']
+// },
+// {
+// label: '{Phone} 1',
+// value: ['{Phone[:0]}']
+// },
+// {
+// label: '{Phone} 2',
+// value: ['{Phone[:1]}']
+// },
+// {
+// label: '{NoteText}',
+// value: ['{NoteText}']
+// }
+// ];
+//
+// Recommended settings if globalGroupContactsByCompanies
+// is set to true:
+// var globalCollectionDisplay=[
+// {
+// label: '{Name}',
+// value: {
+// personal: ['{LastName}', ' {MiddleName}', ' {FirstName}'],
+// company: ['{Company}', ' [{Department}]']
+// }
+// },
+// {
+// label: '{JobTitle}',
+// value: ['{JobTitle}']
+// },
+// {
+// label: '{Email}',
+// value: ['{Email[:0]}']
+// },
+// {
+// label: '{Phone} 1',
+// value: ['{Phone[:0]}']
+// },
+// {
+// label: '{Phone} 2',
+// value: ['{Phone[:1]}']
+// },
+// {
+// label: '{NoteText}',
+// value: ['{NoteText}']
+// }
+// ];
+//
+// NOTE: if left undefined, the recommended settings will be used.
+
+
+// globalCollectionSort
+// This options sets the ordering of contacts in the interface. In general
+// contacts are ordered alphabetically by an internal "sort string" which
+// is created for each contact. Here you can specify how this internal string
+// is created. The value is a simple array holding only the values from the
+// value property defined in the globalCollectionDisplay option.
+// If undefined, the definition from globalCollectionDisplay is used.
+// Example:
+// var globalCollectionSort = [
+// ['{LastName}'],
+// ['{FirstName}'],
+// ['{MiddleName}'],
+// {
+// company: ['{Categories}'],
+// personal: ['{Company}']
+// }
+// ];
+
+
+// globalContactDataMinVisiblePercentage
+// This option defines how the width for columns are computed. If you set
+// it to 1 then 100% of all data in the column will be visible (the column
+// width is determined by the longest string in the column). If you set it
+// to 0.95 then 95% of data will fit into the column width, and the remaining
+// 5% will be truncated (" ...").
+// Example:
+var globalContactDataMinVisiblePercentage=0.95;
+
+
diff --git a/docker/nginx.conf b/docker/nginx.conf
new file mode 100644
index 0000000..8d491ce
--- /dev/null
+++ b/docker/nginx.conf
@@ -0,0 +1,46 @@
+pid /app/tmp/pid;
+worker_processes auto;
+
+error_log /app/logs/error.log;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include conf/mime.types;
+ default_type application/octet-stream;
+
+ client_body_temp_path /app/tmp/client_body_temp;
+ fastcgi_temp_path /app/tmp/fastcgi_temp;
+ proxy_temp_path /app/tmp/proxy_temp;
+
+ error_log /app/logs/error.log;
+ access_log /app/logs/access.log;
+
+ sendfile on;
+ keepalive_timeout 65;
+
+ server {
+ listen 8080;
+ server_name default;
+
+ port_in_redirect off;
+
+ root /app/www;
+ index index.html index.php;
+
+ try_files $uri $uri/ index.php?$query_string;
+
+ location /dav {
+ try_files $uri $uri/ dav.php;
+ }
+
+ location ~* \.php$ {
+ fastcgi_pass 127.0.0.1:9000;
+ include conf/fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name;
+ fastcgi_param SCRIPT_NAME $fastcgi_script_name;
+ }
+ }
+}
diff --git a/docker/php-settings.ini b/docker/php-settings.ini
new file mode 100644
index 0000000..316c7fd
--- /dev/null
+++ b/docker/php-settings.ini
@@ -0,0 +1,5 @@
+file_uploads = On
+memory_limit = 500M
+upload_max_filesize = 1G
+post_max_size = 512M
+max_execution_time = 600 \ No newline at end of file
diff --git a/public/composer.json b/public/composer.json
new file mode 100644
index 0000000..1bf3b93
--- /dev/null
+++ b/public/composer.json
@@ -0,0 +1,7 @@
+{
+ "require": {
+ "php" : ">=7.0",
+ "sabre/dav" : "~4.0",
+ "ralouphie/mimey" : "~2.0"
+ }
+}
diff --git a/public/composer.lock b/public/composer.lock
new file mode 100644
index 0000000..6fa6f30
--- /dev/null
+++ b/public/composer.lock
@@ -0,0 +1,510 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "75864b2454e8320f903e52d25a3ca2e5",
+ "packages": [
+ {
+ "name": "psr/log",
+ "version": "1.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801",
+ "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "Psr/Log/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "time": "2019-11-01T11:05:21+00:00"
+ },
+ {
+ "name": "ralouphie/mimey",
+ "version": "2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ralouphie/mimey.git",
+ "reference": "8f74e6da73f9df7bd965e4e123f3d8fb9acb89ba"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ralouphie/mimey/zipball/8f74e6da73f9df7bd965e4e123f3d8fb9acb89ba",
+ "reference": "8f74e6da73f9df7bd965e4e123f3d8fb9acb89ba",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.4|^7.0"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^1.1",
+ "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Mimey\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
+ }
+ ],
+ "description": "PHP package for converting file extensions to MIME types and vice versa.",
+ "time": "2019-03-08T08:49:03+00:00"
+ },
+ {
+ "name": "sabre/dav",
+ "version": "4.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sabre-io/dav.git",
+ "reference": "fd0234d46c045fc9b35ec06bd2e7b490240e6ade"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sabre-io/dav/zipball/fd0234d46c045fc9b35ec06bd2e7b490240e6ade",
+ "reference": "fd0234d46c045fc9b35ec06bd2e7b490240e6ade",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-date": "*",
+ "ext-dom": "*",
+ "ext-iconv": "*",
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "ext-pcre": "*",
+ "ext-simplexml": "*",
+ "ext-spl": "*",
+ "lib-libxml": ">=2.7.0",
+ "php": ">=7.0.0",
+ "psr/log": "^1.0",
+ "sabre/event": "^5.0",
+ "sabre/http": "^5.0",
+ "sabre/uri": "^2.0",
+ "sabre/vobject": "^4.2.0-alpha1",
+ "sabre/xml": "^2.0.1"
+ },
+ "require-dev": {
+ "evert/phpdoc-md": "~0.1.0",
+ "monolog/monolog": "^1.18",
+ "phpunit/phpunit": "^6"
+ },
+ "suggest": {
+ "ext-curl": "*",
+ "ext-imap": "*",
+ "ext-pdo": "*"
+ },
+ "bin": [
+ "bin/sabredav",
+ "bin/naturalselection"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Sabre\\DAV\\": "lib/DAV/",
+ "Sabre\\DAVACL\\": "lib/DAVACL/",
+ "Sabre\\CalDAV\\": "lib/CalDAV/",
+ "Sabre\\CardDAV\\": "lib/CardDAV/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Evert Pot",
+ "email": "me@evertpot.com",
+ "homepage": "http://evertpot.com/",
+ "role": "Developer"
+ }
+ ],
+ "description": "WebDAV Framework for PHP",
+ "homepage": "http://sabre.io/",
+ "keywords": [
+ "CalDAV",
+ "CardDAV",
+ "WebDAV",
+ "framework",
+ "iCalendar"
+ ],
+ "time": "2019-10-19T07:17:49+00:00"
+ },
+ {
+ "name": "sabre/event",
+ "version": "5.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sabre-io/event.git",
+ "reference": "f5cf802d240df1257866d8813282b98aee3bc548"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sabre-io/event/zipball/f5cf802d240df1257866d8813282b98aee3bc548",
+ "reference": "f5cf802d240df1257866d8813282b98aee3bc548",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": ">=6",
+ "sabre/cs": "~1.0.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Sabre\\Event\\": "lib/"
+ },
+ "files": [
+ "lib/coroutine.php",
+ "lib/Loop/functions.php",
+ "lib/Promise/functions.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Evert Pot",
+ "role": "Developer",
+ "email": "me@evertpot.com",
+ "homepage": "http://evertpot.com/"
+ }
+ ],
+ "description": "sabre/event is a library for lightweight event-based programming",
+ "homepage": "http://sabre.io/event/",
+ "keywords": [
+ "EventEmitter",
+ "async",
+ "coroutine",
+ "eventloop",
+ "events",
+ "hooks",
+ "plugin",
+ "promise",
+ "reactor",
+ "signal"
+ ],
+ "time": "2018-03-05T13:55:47+00:00"
+ },
+ {
+ "name": "sabre/http",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sabre-io/http.git",
+ "reference": "f91c7d4437dcbc6f89c8b64e855e1544f4b60250"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sabre-io/http/zipball/f91c7d4437dcbc6f89c8b64e855e1544f4b60250",
+ "reference": "f91c7d4437dcbc6f89c8b64e855e1544f4b60250",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-mbstring": "*",
+ "php": ">=7.0",
+ "sabre/event": ">=4.0 <6.0",
+ "sabre/uri": "^2.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": ">=6.0.0",
+ "sabre/cs": "~1.0.0"
+ },
+ "suggest": {
+ "ext-curl": " to make http requests with the Client class"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "lib/functions.php"
+ ],
+ "psr-4": {
+ "Sabre\\HTTP\\": "lib/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Evert Pot",
+ "email": "me@evertpot.com",
+ "homepage": "http://evertpot.com/",
+ "role": "Developer"
+ }
+ ],
+ "description": "The sabre/http library provides utilities for dealing with http requests and responses. ",
+ "homepage": "https://github.com/fruux/sabre-http",
+ "keywords": [
+ "http"
+ ],
+ "time": "2018-06-04T21:27:19+00:00"
+ },
+ {
+ "name": "sabre/uri",
+ "version": "2.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sabre-io/uri.git",
+ "reference": "18f454324f371cbcabdad3d0d3755b4b0182095d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sabre-io/uri/zipball/18f454324f371cbcabdad3d0d3755b4b0182095d",
+ "reference": "18f454324f371cbcabdad3d0d3755b4b0182095d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "lib/functions.php"
+ ],
+ "psr-4": {
+ "Sabre\\Uri\\": "lib/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Evert Pot",
+ "email": "me@evertpot.com",
+ "homepage": "http://evertpot.com/",
+ "role": "Developer"
+ }
+ ],
+ "description": "Functions for making sense out of URIs.",
+ "homepage": "http://sabre.io/uri/",
+ "keywords": [
+ "rfc3986",
+ "uri",
+ "url"
+ ],
+ "time": "2019-09-09T23:00:25+00:00"
+ },
+ {
+ "name": "sabre/vobject",
+ "version": "4.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sabre-io/vobject.git",
+ "reference": "6d7476fbd227ae285029c19ad518cd451336038c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sabre-io/vobject/zipball/6d7476fbd227ae285029c19ad518cd451336038c",
+ "reference": "6d7476fbd227ae285029c19ad518cd451336038c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=5.5",
+ "sabre/xml": ">=1.5 <3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "> 4.8.35, <6.0.0"
+ },
+ "suggest": {
+ "hoa/bench": "If you would like to run the benchmark scripts"
+ },
+ "bin": [
+ "bin/vobject",
+ "bin/generate_vcards"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Sabre\\VObject\\": "lib/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Evert Pot",
+ "email": "me@evertpot.com",
+ "homepage": "http://evertpot.com/",
+ "role": "Developer"
+ },
+ {
+ "name": "Dominik Tobschall",
+ "email": "dominik@fruux.com",
+ "homepage": "http://tobschall.de/",
+ "role": "Developer"
+ },
+ {
+ "name": "Ivan Enderlin",
+ "email": "ivan.enderlin@hoa-project.net",
+ "homepage": "http://mnt.io/",
+ "role": "Developer"
+ }
+ ],
+ "description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
+ "homepage": "http://sabre.io/vobject/",
+ "keywords": [
+ "availability",
+ "freebusy",
+ "iCalendar",
+ "ical",
+ "ics",
+ "jCal",
+ "jCard",
+ "recurrence",
+ "rfc2425",
+ "rfc2426",
+ "rfc2739",
+ "rfc4770",
+ "rfc5545",
+ "rfc5546",
+ "rfc6321",
+ "rfc6350",
+ "rfc6351",
+ "rfc6474",
+ "rfc6638",
+ "rfc6715",
+ "rfc6868",
+ "vCalendar",
+ "vCard",
+ "vcf",
+ "xCal",
+ "xCard"
+ ],
+ "time": "2019-12-18T19:29:43+00:00"
+ },
+ {
+ "name": "sabre/xml",
+ "version": "2.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sabre-io/xml.git",
+ "reference": "f08a58f57e2b0d7df769a432756aa371417ab9eb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sabre-io/xml/zipball/f08a58f57e2b0d7df769a432756aa371417ab9eb",
+ "reference": "f08a58f57e2b0d7df769a432756aa371417ab9eb",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-xmlreader": "*",
+ "ext-xmlwriter": "*",
+ "lib-libxml": ">=2.6.20",
+ "php": ">=7.0",
+ "sabre/uri": ">=1.0,<3.0.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Sabre\\Xml\\": "lib/"
+ },
+ "files": [
+ "lib/Deserializer/functions.php",
+ "lib/Serializer/functions.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Evert Pot",
+ "email": "me@evertpot.com",
+ "homepage": "http://evertpot.com/",
+ "role": "Developer"
+ },
+ {
+ "name": "Markus Staab",
+ "email": "markus.staab@redaxo.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "sabre/xml is an XML library that you may not hate.",
+ "homepage": "https://sabre.io/xml/",
+ "keywords": [
+ "XMLReader",
+ "XMLWriter",
+ "dom",
+ "xml"
+ ],
+ "time": "2019-08-14T15:41:34+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=7.0"
+ },
+ "platform-dev": []
+}
diff --git a/public/dav.php b/public/dav.php
new file mode 100644
index 0000000..c203927
--- /dev/null
+++ b/public/dav.php
@@ -0,0 +1,75 @@
+<?php
+error_reporting(E_ALL);
+ini_set("log_errors", true);
+ini_set('display_errors', true);
+ini_set('error_log', './error.log');
+ini_set('memory_limit', '1024M');
+
+define("PATH", __DIR__.'/');
+date_default_timezone_set('Europe/Berlin');
+
+if (!file_exists('vendor/')) {
+ die('<h1>Incomplete installation</h1>Dependencies have not been installed.');
+}
+
+require 'vendor/autoload.php';
+require PATH.'lib/JSONDB.php';
+require PATH.'lib/Helpers.php';
+require PATH.'lib/UserManager.php';
+require PATH.'lib/GenericCollectionManager.php';
+require PATH.'lib/AddressbookCollectionManager.php';
+require PATH.'lib/CalendarCollectionManager.php';
+
+require PATH.'lib/Sabre/SabrePrincipalJsonBackend.php';
+require PATH.'lib/Sabre/SabreAuthenticationJsonBackend.php';
+require PATH.'lib/Sabre/SabrePropertyStorageJsonBackend.php';
+require PATH.'lib/Sabre/SabreCardDAVJsonBackend.php';
+require PATH.'lib/Sabre/SabreCalDAVJsonBackend.php';
+require PATH.'lib/Sabre/FilesPlugin.php';
+
+$db = new JSONDB(PATH.'/../data');
+
+$userMgr = new UserManager($db);
+$cardMgr = new AddressbookCollectionManager($db);
+$calMgr = new CalendarCollectionManager($db);
+
+$authBackend = new SabreAuthenticationJsonBackend($userMgr);
+$propertyStorageBackend = new SabrePropertyStorageJsonBackend($db);
+$principalBackend = new SabrePrincipalJsonBackend($userMgr);
+$cardDavBackend = new SabreCardDAVJsonBackend($cardMgr);
+$calDavBackend = new SabreCalDAVJsonBackend($calMgr);
+
+$server = new Sabre\DAV\Server([
+ new Sabre\DAVACL\PrincipalCollection($principalBackend),
+ //CardDAV-Sever
+ new Sabre\CardDAV\AddressBookRoot($principalBackend, $cardDavBackend),
+ //CalDAV-Server
+ new Sabre\CalDAV\CalendarRoot($principalBackend, $calDavBackend),
+ //WebDAV-Server
+ new FilesPlugin\FilesCollection($principalBackend, PATH.'data/files/'),
+ ]);
+
+
+$server->setBaseUri('/dav/');
+
+$server->addPlugin(new Sabre\DAV\Auth\Plugin($authBackend, 'tinyDAV'));
+$server->addPlugin(new Sabre\DAVACL\Plugin());
+$server->addPlugin(new Sabre\DAV\PropertyStorage\Plugin($propertyStorageBackend));
+$server->addPlugin(new Sabre\DAV\Sync\Plugin());
+
+//CardDAV-Server
+$server->addPlugin(new Sabre\CardDAV\Plugin());
+$server->addPlugin(new Sabre\CardDAV\VCFExportPlugin());
+
+//CalDAV-Server
+$server->addPlugin(new Sabre\CalDAV\Plugin());
+$server->addPlugin(new Sabre\CalDAV\ICSExportPlugin());
+$server->addPlugin(new Sabre\CalDAV\Schedule\Plugin());
+
+//WebDAV-Server
+$server->addPlugin(new FilesPlugin\Plugin(PATH.'data/files/'));
+
+//Fancy Web
+$server->addPlugin(new Sabre\DAV\Browser\Plugin());
+
+$server->exec(); \ No newline at end of file
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..f498120
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,333 @@
+<?php
+//For debuging. remove in production
+error_reporting(E_ALL);
+ini_set("log_errors", true);
+ini_set('display_errors', true);
+ini_set('error_log', './error.log');
+
+define('PATH', __DIR__);
+session_start();
+
+require PATH.'/lib/JSONDB.php';
+require PATH.'/lib/Template.php';
+require PATH.'/lib/Router.php';
+require PATH.'/lib/Helpers.php';
+require PATH.'/lib/UserManager.php';
+require PATH.'/lib/GenericCollectionManager.php';
+require PATH.'/lib/AddressbookCollectionManager.php';
+require PATH.'/lib/CalendarCollectionManager.php';
+
+$pagevars = [];
+
+$db = new JSONDB(PATH.'/../data');
+$userMgr = new UserManager($db);
+$cardMgr = new AddressbookCollectionManager($db);
+$calMgr = new CalendarCollectionManager($db);
+$tpl = new Template(PATH.'/template/', [
+ 'IS_ADMIN' => $userMgr->isAdmin(),
+ ]);
+
+//GET
+Router::add('GET', '/', function(){
+ global $userMgr;
+
+ if (!$userMgr->isLoggedIn()){
+ header("Location: /login");
+ exit();
+ }
+
+ header("Location: /overview");
+});
+
+Router::add('GET', '/login', function(){
+ global $tpl;
+
+ $tpl->render('login', [
+ 'PAGE' => 'Login',
+ ]);
+});
+
+Router::add('GET', '/logout', function(){
+ global $userMgr;
+ $userMgr->logout();
+ header("Location: /");
+});
+
+Router::add('GET', '/overview', function(){
+ global $tpl, $db, $userMgr, $cardMgr, $calMgr;
+ $userMgr->checkLoggedIn();
+
+ if (!$userMgr->isAdmin()) {
+ $addressbooks = $cardMgr->getCollections('principals/'.$userMgr->getLoggedInAccount()['username']);
+ $calendars = $calMgr->getCollections('principals/'.$userMgr->getLoggedInAccount()['username']);
+
+ foreach ($addressbooks as $addressbook) {
+ $tpl->blockAssign('addressbooks', [
+ 'ID' => $addressbook['id'],
+ 'DISPLAYNAME' => $addressbook['displayname'],
+ 'URI' => $addressbook['uri'],
+ ]);
+ }
+
+ foreach ($calendars as $calendar) {
+ $tpl->blockAssign('calendars', [
+ 'ID' => $calendar['id'],
+ 'DISPLAYNAME' => $calendar['displayname'],
+ 'URI' => $calendar['uri'],
+ ]);
+ }
+
+ } else {
+ $users = $userMgr->getAll();
+
+ foreach ($users as $user) {
+ $tpl->blockAssign('users', [
+ 'ID' => $user->id,
+ 'USERNAME' => $user->username,
+ 'ACTIVE' => $user->active,
+ ]);
+ }
+ }
+
+ $tpl->render('overview', [
+ 'PAGE' => 'Overview',
+ 'USERNAME' => $userMgr->getLoggedInAccount()['username'],
+ ]);
+});
+
+Router::add('GET', '/update', function(){
+ global $tpl, $userMgr;
+ $userMgr->checkLoggedIn();
+
+ $username = strtolower(trim(Helpers::getVar('username')));
+
+ switch(Helpers::getVar('type')) {
+ case 'user':
+
+ if (!$userMgr->isAdmin()) {
+ header("Location: /overview");
+ exit();
+ }
+
+ if (!$userMgr->exists($username)) {
+ header("Location: /overview");
+ exit();
+ }
+
+ $user = $userMgr->get($username);
+
+ $tpl->render('update', [
+ 'PAGE' => 'Update User',
+ 'TYPE' => 'user',
+ 'USERNAME' => $user['username'],
+ 'ACTIVE' => $user['active']
+ ]);
+
+ default:
+ header("Location: /overview");
+ }
+});
+
+//POST
+Router::add('POST', '/login', function(){
+ global $tpl, $userMgr;
+
+ try {
+ $userMgr->checkLogin(strtolower(trim(Helpers::postVar('username'))), Helpers::postVar('password'));
+ header("Location: /overview");
+ } catch ( Exception $e ) {
+ $tpl->render('login', [
+ 'PAGE' => 'Login',
+ 'MSG' => $e->getMessage(),
+ ]);
+ }
+});
+
+Router::add('POST', '/create', function(){
+ global $tpl, $userMgr, $cardMgr, $calMgr;
+ $userMgr->checkLoggedIn();
+
+ $username = strtolower(trim(Helpers::postVar('username')));
+ $msg = '';
+
+ switch(Helpers::postVar('type')) {
+ case 'user':
+ if (!$userMgr->isAdmin()) {
+ header("Location: /overview");
+ exit();
+ }
+
+ try {
+ $userMgr->create($username, Helpers::postVar('password'), true);
+ $cardMgr->createCollection('principals/'.$username, 'default', ['displayname' => 'Default-Addressbook']);
+ $calMgr->createCollection('principals/'.$username, 'default', ['displayname' => 'Default-Calendar']);
+
+ header("Location: /overview");
+ } catch ( Exception $e ) {
+ $tpl->render('message', [
+ 'PAGE' => 'Message',
+ 'MSG' => $e->getMessage(),
+ ]);
+ }
+
+ case 'addressbook':
+ case 'calendar':
+ if ($userMgr->getLoggedInAccount()['username'] !== $username && !$userMgr->isAdmin()) {
+ header("Location: /overview");
+ exit();
+ }
+
+ $uri = Helpers::postVar('uri');
+ $displayname = Helpers::postVar('displayname');
+
+ try {
+ if (empty($displayname)) throw new Exception('Displayname can\'t be empty.');
+
+ if (empty($uri)) {
+ $uri = preg_replace("/[^A-Za-z0-9 ]/", '', $displayname);
+ }
+
+ if (empty($uri)) throw new Exception('URI can\'t be empty.');
+
+ if (Helpers::postVar('type') !== 'addressbook') {
+ $calMgr->createCollection('principals/'.$username, $uri, ['displayname' => $displayname]);
+ } else {
+ $cardMgr->createCollection('principals/'.$username, $uri, ['displayname' => $displayname]);
+ }
+
+ header("Location: /overview");
+ } catch ( Exception $e ) {
+ $tpl->render('message', [
+ 'PAGE' => 'Message',
+ 'MSG' => $e->getMessage(),
+ ]);
+ }
+
+ default:
+ header("Location: /overview");
+ exit();
+ }
+
+ $tpl->render('message', [
+ 'PAGE' => 'Oofff',
+ 'MSG' => $msg,
+ ]);
+});
+
+Router::add('POST', '/update', function(){
+ global $tpl, $userMgr;
+ $userMgr->checkLoggedIn();
+
+ $username = strtolower(trim(Helpers::postVar('username')));
+ $msg = '';
+
+ switch(Helpers::postVar('type')) {
+ case 'password':
+ $password1 = Helpers::postVar('password1');
+ $password2 = Helpers::postVar('password2');
+
+ if ($userMgr->getLoggedInAccount()['username'] !== $username && !$userMgr->isAdmin()) {
+ header("Location: /overview");
+ exit();
+ } elseif ($password1 !== $password2) {
+ $msg = 'Passwords do not match!';
+ } else {
+ try {
+ $userMgr->updatePassword($username, $password1);
+ header("Location: /overview");
+
+ } catch (Exception $e) {
+ $msg = $e->getMessage();
+ }
+ }
+
+ case 'user':
+ $active = boolval(Helpers::postVar('active'));
+
+ if (!$userMgr->isAdmin()) {
+ header("Location: /overview");
+ exit();
+ }
+
+ try {
+ if (!$active) {
+ $userMgr->disable($username);
+ } else {
+ $userMgr->enable($username);
+ }
+
+ header("Location: /overview");
+
+ } catch (Exception $e) {
+ $msg = $e->getMessage();
+ }
+
+ default:
+ header("Location: /overview");
+ exit();
+ }
+
+ $tpl->render('message', [
+ 'PAGE' => 'Oofff',
+ 'MSG' => $msg,
+ ]);
+});
+
+Router::add('POST', '/delete', function(){
+ global $tpl, $userMgr, $cardMgr, $calMgr;
+ $userMgr->checkLoggedIn();
+
+ $username = strtolower(trim(Helpers::postVar('username')));
+ $msg = '';
+
+ switch (Helpers::postVar('type')) {
+ case 'user':
+ if ($userMgr->getLoggedInAccount()['username'] !== $username && !$userMgr->isAdmin()) {
+ header("Location: /overview");
+ exit();
+ }
+
+ try {
+ $userMgr->delete($username);
+
+ $addressbooks = $cardMgr->getCollections('principals/'.$username);
+ $calendars = $calMgr->getCollections('principals/'.$username);
+
+ foreach ($addressbooks as $addressbook) {
+ $cardMgr->deleteCollection($addressbook['id']);
+ }
+
+ foreach ($calendars as $calendar) {
+ $calMgr->deleteCollection($calendar['id']);
+ }
+
+ if ($userMgr->getLoggedInAccount()['username'] !== $username) {
+ header("Location: /overview");
+ } else {
+ $userMgr->logout();
+ header("Location: /");
+ }
+ } catch (Exception $e) {
+ $msg = $e->getMessage();
+ }
+
+ default:
+ header("Location: /overview");
+ exit();
+ }
+
+ $tpl->render('message', [
+ 'PAGE' => 'Oofff',
+ 'MSG' => $msg,
+ ]);
+});
+
+Router::pathNotFound(function(){
+ header("Loctaion: /");
+});
+
+Router::methodNotAllowed(function(){
+ header("Loctaion: /");
+});
+
+Router::run('/'); \ No newline at end of file
diff --git a/public/install.php b/public/install.php
new file mode 100644
index 0000000..08b1a94
--- /dev/null
+++ b/public/install.php
@@ -0,0 +1,24 @@
+<?php
+define('PATH', __DIR__);
+
+require(PATH.'/lib/JSONDB.php');
+
+if (php_sapi_name() !== 'cli') {
+ die("run me on a shell.");
+}
+
+if (!file_exists(PATH.'/../data/users.json')) {
+ mkdir(PATH.'/../data');
+ mkdir(PATH.'/../data/addressbooks');
+ mkdir(PATH.'/../data/calendars');
+ mkdir(PATH.'/../data/files');
+
+
+ $db = new JSONDB(PATH.'/../data');
+ $db->insert( 'users.json', [
+ 'id' => 1,
+ 'username' => 'admin',
+ 'password' => password_hash("admin", PASSWORD_DEFAULT),
+ 'active' => true,
+ ]);
+} \ No newline at end of file
diff --git a/public/lib/AddressbookCollectionManager.php b/public/lib/AddressbookCollectionManager.php
new file mode 100644
index 0000000..c8e9162
--- /dev/null
+++ b/public/lib/AddressbookCollectionManager.php
@@ -0,0 +1,47 @@
+<?php
+
+class AddressbookCollectionManager extends GenericCollectionManager {
+ public $dataFolder = PATH.'/../data/';
+ public $collectionsFile = 'addressbooks/addressbooks.json';
+ public $collectionsFolder = 'addressbooks/';
+ public $collectionType = 'Addressbook';
+ public $datafield = 'carddata';
+
+
+ public function newCollection ($username, $uri, array $properties) {
+ $collection = [
+ 'id' => $this->getHighestCollectionId()+1,
+ 'principaluri' => $username,
+ 'displayname' => NULL,
+ 'uri' => $uri,
+ 'description' => NULL,
+ 'synctoken' => 1,
+ ];
+
+ $collection = array_merge($collection, $properties);
+
+ return $collection;
+ }
+
+ public function newObject ($collectionId, $uri, $data, $extraData = NULL) {
+ $object = [
+ 'id' => $this->getHighestObjectId($collectionId)+1,
+ 'uri' => $uri,
+ 'etag' => md5($data),
+ 'size' => strlen($data),
+ 'lastmodified' => time(),
+ ];
+
+ return $object;
+ }
+
+ public function newObjectUpdate ($collectionId, $uri, $data, $extraData = NULL) {
+ $update = [
+ 'etag' => md5($data),
+ 'size' => strlen($data),
+ 'lastmodified' => time(),
+ ];
+
+ return $update;
+ }
+}
diff --git a/public/lib/CalendarCollectionManager.php b/public/lib/CalendarCollectionManager.php
new file mode 100644
index 0000000..130da63
--- /dev/null
+++ b/public/lib/CalendarCollectionManager.php
@@ -0,0 +1,78 @@
+<?php
+
+class CalendarCollectionManager extends GenericCollectionManager {
+ public $dataFolder = PATH.'/../data/';
+ public $collectionsFile = 'calendars/calendars.json';
+ public $collectionsFolder = 'calendars/';
+ public $collectionType = 'Calendar';
+ public $datafield = 'calendardata';
+
+
+ public function newCollection ($username, $uri, array $properties) {
+ $collection = [
+ 'id' => $this->getHighestCollectionId($username)+1,
+ 'principaluri' => $username,
+ 'uri' => $uri,
+ 'displayname' => NULL,
+ 'description' => NULL,
+ 'timezone' => NULL,
+ 'calendarorder' => NULL,
+ 'calendarcolor' => NULL,
+ 'components' => 'VEVENT,VTODO',
+ 'synctoken' => 1,
+ ];
+
+ $collection = array_merge($collection, $properties);
+
+ return $collection;
+ }
+
+ public function newObject ($collectionId, $uri, $data, $extraData = NULL) {
+ $object = [
+ 'id' => $this->getHighestObjectId($collectionId)+1,
+ 'uri' => $uri,
+ 'componenttype' => NULL,
+ 'firstoccurence' => NULL,
+ 'lastoccurence' => NULL,
+ 'uid' => NULL,
+ 'etag' => md5($data),
+ 'lastmodified' => time(),
+ 'size' => strlen($data),
+ ];
+
+ if ($extraData !== NULL) {
+ $object = array_merge($object, $extraData);
+ }
+
+ return $object;
+ }
+
+ public function newObjectUpdate ($collectionId, $uri, $data, $extraData = NULL) {
+ $update = [
+ 'etag' => md5($data),
+ 'size' => strlen($data),
+ 'lastmodified' => time(),
+ ];
+
+ if ($extraData !== NULL) {
+ $update = array_merge($update, $extraData);
+ }
+
+ return $update;
+ }
+
+ public function getUriByUID($username, $uid) {
+ $collections = $this->getCollections($username);
+
+ foreach ($collections as $collection) {
+ $objects = $this->getObjects($collection['id']);
+
+ foreach ($objects as $object) {
+ if ($object['uid'] == $uid) return $collection['uri'].'/'.$object['uri'];
+ }
+ }
+
+ return null;
+ }
+}
+
diff --git a/public/lib/Database.php b/public/lib/Database.php
new file mode 100644
index 0000000..06ec260
--- /dev/null
+++ b/public/lib/Database.php
@@ -0,0 +1,112 @@
+<?php
+
+class Database {
+ private $db;
+ public $lastQuery;
+
+ public $handleError = false;
+
+ public function __construct(array $options) {
+ try {
+ if ($options['type'] !== 'mysql') {
+ $this->db = new PDO('sqlite:'.$options['file']);
+ } else {
+ $this->db = new PDO('mysql:host='.$options['host'].';dbname='.$options['name'], $options['user'], $options['password']);
+ }
+ } catch (PDOException $e) {
+ die('Connection to database faild: '.$e->getMessage());
+ }
+
+ $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ $this->db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
+ }
+
+ public function query($sql, array $args = null) {
+ try {
+ if (!is_null($args)) {
+ $this->lastQuery = $this->db->prepare($sql);
+ $this->lastQuery->execute($args);
+ } else {
+ $this->lastQuery = $this->db->query($sql);
+ }
+
+ return $this->lastQuery;
+ } catch (PDOException $e) {
+ if ($this->handleError) {
+ $this->error($e->getMessage(), $sql, $e->getCode());
+ } else {
+ throw $e;
+ }
+ }
+ }
+
+ public function prepare($sql) {
+ try {
+ return $this->db->prepare($sql);
+ } catch (PDOException $e) {
+ if ($this->handleError) {
+ $this->error($e->getMessage(), $sql, $e->getCode());
+ } else {
+ throw $e;
+ }
+ }
+ }
+
+ public function fetchObject(PDOStatement $stmt = null) {
+ if (!is_null($stmt)) {
+ return $stmt->fetch(PDO::FETCH_OBJ);
+ } else {
+ return $this->lastQuery->fetch(PDO::FETCH_OBJ);
+ }
+ }
+
+ public function fetch(PDOStatement $stmt = null, $fetch_style = FETCH_ASSOC) {
+ if (!is_null($stmt)) {
+ return $stmt->fetch(PDO::$fetch_style);
+ } else {
+ return $this->lastQuery->fetch(PDO::$fetch_style);
+ }
+ }
+
+ public function numRows(PDOStatement $stmt = null) {
+ if (!is_null($stmt)) {
+ return $stmt->rowCount();
+ } else {
+ return $this->lastQuery->rowCount();
+ }
+ }
+
+ function tableExists($table) {
+ try {
+ $result = $this->query('SHOW TABLES LIKE ?', [$table])->numRows();
+ } catch (Exception $e) {
+ return $e->getMessage();
+ }
+
+ return $result > 0 ? true : false;
+ }
+
+ public function insertID() {
+ return $this->db->lastInsertId();
+ }
+
+ public function beginTransaction() {
+ return $this->db->beginTransaction();
+ }
+
+ public function commit() {
+ return $this->db->commit();
+ }
+
+ public function rollback() {
+ return $this->db->rollBack();
+ }
+
+ public function errorInfo() {
+ return $this->db->errorinfo();
+ }
+
+ protected function error($msg, $sql, $code) {
+ Helpers::log(Helpers::LOG_ERROR, $msg . '<br><br><code>' . $sql . '</code>');
+ }
+} \ No newline at end of file
diff --git a/public/lib/GenericCollectionManager.php b/public/lib/GenericCollectionManager.php
new file mode 100644
index 0000000..76b9273
--- /dev/null
+++ b/public/lib/GenericCollectionManager.php
@@ -0,0 +1,237 @@
+<?php
+
+class GenericCollectionManager {
+ private $db;
+
+// public $dataFolder = PATH.'/data/';
+// public $collectionsFile = 'addressbooks/addressbooks.json';
+// public $collectionsFolder = 'addressbooks/';
+// public $collectionType = 'Addressbook';
+// public $datafield = 'carddata';
+
+
+// public function newCollection ($username, $uri, array $properties) {}
+// public function newObject ($collectionId, $uri, $data, $extraData) {}
+// public function newObjectUpdate ($collectionId, $uri, $data, $extraData) {}
+
+ public function __construct (JSONDB $database) {
+ $this->db = $database;
+ }
+
+ //
+ // Collections
+ //
+
+ public function collectionExists ($collectionId) {
+ return (!$this->getCollection($collectionId)) ? false : true;
+ }
+
+ public function getCollection ($collectionId) {
+ $result = $this->db->select('*')
+ ->from($this->collectionsFile)
+ ->where(['id' => $collectionId])
+ ->get();
+
+ if (!isset($result[0])) return false;
+
+ return $result[0];
+ }
+
+ public function getCollections ($username) {
+ $result = $this->db->select('*')
+ ->from($this->collectionsFile)
+ ->where(['principaluri' => $username])
+ ->get();
+
+ return $result;
+ }
+
+ public function getHighestCollectionId () {
+ $data = $this->db->select('id')
+ ->from($this->collectionsFile)
+ ->order_by('id', JSONDB::ASC)
+ ->get();
+
+ return end($data)['id'];
+ }
+
+
+ public function createCollection ($username, $uri, array $properties) {
+ if(!preg_match('/^[\w-]+$/', $uri)) throw new Exception('URI contains not allowed characters.');
+
+ $collections = $this->getCollections($username);
+ foreach ($collections as $collection) {
+ if ($collection['uri'] == $uri) throw new Exception('An '.strtolower($this->collectionType).' with this URI already exist.');
+ }
+
+ $collection = $this->newCollection($username, $uri, $properties);
+ $this->db->insert($this->collectionsFile, $collection);
+ mkdir($this->dataFolder.$this->collectionsFolder.$collection['id']);
+ mkdir($this->dataFolder.$this->collectionsFolder.$collection['id'].'/data');
+
+ return $collection['id'];
+ }
+
+ public function updateCollection ($collectionId, $properties) {
+ if (!$this->exists($id)) throw new Exception($this->collectionType.' doesn\'t exist!');
+
+ $this->db->update($properties)
+ ->from($this->collectionsFile)
+ ->where(['id' => $collectionId])
+ ->trigger();
+
+ $this->addChange($collectionId, '', 2);
+ }
+
+ public function deleteCollection ($collectionId) {
+ $this->db->delete()
+ ->from($this->collectionsFile)
+ ->where(['id' => $collectionId])
+ ->trigger();
+
+ Helpers::delete($this->dataFolder.$this->collectionsFolder.$id);
+ }
+
+
+ //
+ // Objects
+ //
+
+ public function getHighestObjectId ($collectionId) {
+ $data = $this->db->select('id')
+ ->from($this->collectionsFolder.$collectionId.'/objects.json')
+ ->order_by('id', JSONDB::ASC)
+ ->get();
+
+ return end($data)['id'];
+ }
+
+ public function getObject ($collectionId, $uri) {
+ $objects = $result = $this->db->select('*')
+ ->from($this->collectionsFolder.$collectionId.'/objects.json')
+ ->where(['uri' => $uri])
+ ->get();
+
+ if (!isset($objects[0])) return false;
+
+ $result = $objects[0];
+ $result[$this->datafield] = file_get_contents($this->dataFolder.$this->collectionsFolder.$collectionId.'/data/'.$objects[0]['uri']);
+
+ return $result;
+ }
+
+ public function getObjects ($collectionId) {
+ $objects = $this->db->select('*')
+ ->from($this->collectionsFolder.$collectionId.'/objects.json')
+ ->get();
+
+ $results = [];
+ foreach ($objects as $object) {
+ $results[] = get_object_vars($object);
+ }
+
+ return $results;
+ }
+
+ public function createObject ($collectionId, $uri, $data, $extraData = NULL) {
+ $object = $this->newObject($collectionId, $uri, $data, $extraData);
+
+ $this->db->insert($this->collectionsFolder.$collectionId.'/objects.json', $object);
+ file_put_contents($this->dataFolder.$this->collectionsFolder.$collectionId.'/data/'.$uri, $data);
+ $this->addChange($collectionId, $uri, 1);
+
+ return $object['etag'];
+ }
+
+ public function updateObject ($collectionId, $uri, $data, $extraData = NULL) {
+ $update = $this->newObjectUpdate($collectionId, $uri, $data, $extraData);
+
+ $this->db->update($update)
+ ->from($this->collectionsFolder.$collectionId.'/objects.json')
+ ->where(['uri' => $uri])
+ ->trigger();
+
+ file_put_contents($this->dataFolder.$this->collectionsFolder.$collectionId.'/data/'.$uri, $data);
+ $this->addChange($collectionId, $uri, 2);
+
+ return $update['etag'];
+ }
+
+ public function deleteObject ($collectionId, $uri) {
+ $result = $this->db->delete()
+ ->from($this->collectionsFolder.$collectionId.'/objects.json')
+ ->where(['uri' => $uri])
+ ->trigger();
+
+ unlink($this->dataFolder.$this->collectionsFolder.$collectionId.'/data/'.$uri);
+ $this->addChange($collectionId, $uri, 3);
+
+ return $result;
+ }
+
+ public function addChange ($collectionId, $uri, $operation) {
+ $synctoken = $this->getCollection($collectionId)['synctoken'];
+
+ $this->db->insert($this->collectionsFolder.$collectionId.'/changes.json', [
+ 'synctoken' => $synctoken,
+ 'operation' => $operation,
+ 'uri' => $uri,
+ ]);
+
+ $this->db->update(['synctoken' => $synctoken+1])
+ ->from($this->collectionsFile)
+ ->where(['id' => $collectionId])
+ ->trigger();
+ }
+
+ public function getChanges ($collectionId, $syncToken, $syncLevel) {
+ $currentToken = $this->getCollection($collectionId)['synctoken'];
+
+ if (is_null($currentToken)) {
+ return null;
+ }
+
+ $result = [
+ 'syncToken' => $currentToken,
+ 'added' => [],
+ 'modified' => [],
+ 'deleted' => [],
+ ];
+
+ if ($syncToken) {
+ $query = $this->db->select('*')
+ ->from($this->collectionsFolder.$collectionId.'/changes.json')
+ ->order_by('synctoken', JSONDB::ASC)
+ ->get();
+
+ $changes = [];
+
+ foreach ($query as $data) {
+ if ($data['synctoken'] >= $syncToken) $changes[$data['uri']] = $data['operation'];
+ if ($data['synctoken'] < $currentToken) $changes[$data['uri']] = $data['operation'];
+ }
+
+ foreach ($changes as $uri => $operation) {
+ switch ($operation) {
+ case 1:
+ $result['added'][] = $uri;
+ break;
+ case 2:
+ $result['modified'][] = $uri;
+ break;
+ case 3:
+ $result['deleted'][] = $uri;
+ break;
+ }
+ }
+ } else {
+ $objects = $this->getObjects($collectionId);
+
+ foreach ($objects as $object) {
+ $result['added'][] = $object['uri'];
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/public/lib/Helpers.php b/public/lib/Helpers.php
new file mode 100644
index 0000000..ebd7756
--- /dev/null
+++ b/public/lib/Helpers.php
@@ -0,0 +1,40 @@
+<?php
+
+abstract class Helpers {
+ public static function requestVar ($name) {
+ return (isset($_REQUEST[$name])) ? trim($_REQUEST[$name]) : NULL;
+ }
+
+ public static function getVar ($name) {
+ return (isset($_GET[$name])) ? trim($_GET[$name]) : NULL;
+ }
+
+ public static function postVar ($name) {
+ return (isset($_POST[$name])) ? trim($_POST[$name]) : NULL;
+ }
+
+ public static function isDateValid ($date, $format) {
+ $d = DateTime::createFromFormat($format, $date);
+ return $d && $d->format($format) == $date;
+ }
+
+ public static function startsWith ($string, $startString) {
+ $len = strlen($startString);
+ return (substr($string, 0, $len) === $startString);
+ }
+
+ public static function delete ($target) {
+ if(is_dir($target)){
+ $files = glob( $target . '*', GLOB_MARK ); //GLOB_MARK adds a slash to directories returned
+
+ foreach( $files as $file ){
+ Helpers::delete($file);
+ }
+
+ rmdir($target);
+ } elseif(is_file($target)) {
+ unlink($target);
+ }
+ }
+}
+
diff --git a/public/lib/JSONDB.php b/public/lib/JSONDB.php
new file mode 100644
index 0000000..22446d6
--- /dev/null
+++ b/public/lib/JSONDB.php
@@ -0,0 +1,446 @@
+<?php
+declare( strict_types = 1 );
+
+class JSONDB {
+ public $file, $content = [];
+ private $where, $select, $merge, $update;
+ private $delete = false;
+ private $last_indexes = [];
+ private $order_by = [];
+ protected $dir;
+ private $json_opts = [];
+ const ASC = 1;
+ const DESC = 0;
+
+ public function __construct( $dir, $json_encode_opt = JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT ) {
+ $this->dir = $dir;
+ $this->json_opts[ 'encode' ] = $json_encode_opt;
+ }
+
+ private function check_file() {
+ /**
+ * Checks and validates if JSON file exists
+ *
+ * @return bool
+ */
+
+ // Checks if JSON file exists, if not create
+ if( !file_exists( $this->file ) ) {
+ $this->commit();
+ }
+
+ // Read content of JSON file
+ $content = file_get_contents( $this->file );
+ $content = json_decode( $content );
+
+ // Check if its arrays of jSON
+ if( !is_array( $content ) && is_object( $content ) ) {
+ throw new \Exception( 'An array of json is required: Json data enclosed with []' );
+ return false;
+ }
+ // An invalid jSON file
+ elseif( !is_array( $content ) && !is_object( $content ) ) {
+ throw new \Exception( 'json is invalid' );
+ return false;
+ }
+ else
+ return true;
+ }
+
+ public function select( $args = '*' ) {
+ /**
+ * Explodes the selected columns into array
+ *
+ * @param type $args Optional. Default *
+ * @return type object
+ */
+
+ // Explode to array
+ $this->select = explode( ',', $args );
+ // Remove whitespaces
+ $this->select = array_map( 'trim', $this->select );
+ // Remove empty values
+ $this->select = array_filter( $this->select );
+
+ return $this;
+ }
+
+ public function from( $file ) {
+ /**
+ * Loads the jSON file
+ *
+ * @param type $file. Accepts file path to jSON file
+ * @return type object
+ */
+
+ $this->file = sprintf( '%s/%s.json', $this->dir, str_replace( '.json', '', $file ) ); // Adding .json extension is no longer necessary
+
+ // Reset where
+ $this->where( [] );
+ $this->content = '';
+
+ // Reset order by
+ $this->order_by = [];
+
+ if( $this->check_file() ) {
+ $this->content = ( array ) json_decode( file_get_contents( $this->file ) );
+ }
+ return $this;
+ }
+
+ public function where( array $columns, $merge = 'OR' ) {
+ $this->where = $columns;
+ $this->merge = $merge;
+ return $this;
+ }
+
+ public function delete() {
+ $this->delete = true;
+ return $this;
+ }
+
+ public function update( array $columns ) {
+ $this->update = $columns;
+ return $this;
+ }
+
+ /**
+ * Inserts data into json file
+ *
+ * @param string $file json filename without extension
+ * @param array $values Array of columns as keys and values
+ *
+ * @return array $last_indexes Array of last index inserted
+ */
+ public function insert( $file, array $values ) : array {
+ $this->from( $file );
+
+ if( !empty( $this->content[ 0 ] ) ) {
+ $nulls = array_diff_key( ( array ) $this->content[ 0 ], $values );
+ if( $nulls ) {
+ $nulls = array_map( function() {
+ return '';
+ }, $nulls );
+ $values = array_merge( $values, $nulls );
+ }
+ }
+
+ if( !empty( $this->content ) && array_diff_key( $values, (array ) $this->content[ 0 ] ) ) {
+ throw new Exception( 'Columns must match as of the first row' );
+ }
+ else {
+ $this->content[] = ( object ) $values;
+ $this->last_indexes = [ ( count( $this->content ) - 1 ) ];
+ $this->commit();
+ }
+ return $this->last_indexes;
+ }
+
+ public function commit() {
+ $f = fopen( $this->file, 'w+' );
+ fwrite( $f, ( !$this->content ? '[]' : json_encode( $this->content, $this->json_opts[ 'encode' ] ) ) );
+ fclose( $f );
+ }
+
+ private function _update() {
+ if( !empty( $this->last_indexes ) && !empty( $this->where ) ) {
+ foreach( $this->content as $i => $v ) {
+ if( in_array( $i, $this->last_indexes ) ) {
+ $content = ( array ) $this->content[ $i ];
+ if( !array_diff_key( $this->update, $content ) ) {
+ $this->content[ $i ] = ( object ) array_merge( $content, $this->update );
+ }
+ else
+ throw new Exception( 'Update method has an off key' );
+ }
+ else
+ continue;
+ }
+ }
+ elseif( !empty( $this->where ) && empty( $this->last_indexes ) ) {
+ null;
+ }
+ else {
+ foreach( $this->content as $i => $v ) {
+ $content = ( array ) $this->content[ $i ];
+ if( !array_diff_key( $this->update, $content ) )
+ $this->content[ $i ] = ( object ) array_merge( $content, $this->update );
+ else
+ throw new Exception( 'Update method has an off key ' );
+ }
+ }
+ }
+
+ /**
+ * Prepares data and written to file
+ *
+ * @return object $this
+ */
+ public function trigger() {
+ $content = ( !empty( $this->where ) ? $this->where_result() : $this->content );
+ $return = false;
+ if( $this->delete ) {
+ if( !empty( $this->last_indexes ) && !empty( $this->where ) ) {
+ $this->content = array_filter($this->content, function( $index ) {
+ return !in_array( $index, $this->last_indexes );
+ }, ARRAY_FILTER_USE_KEY );
+
+ $this->content = array_values( $this->content );
+ }
+ elseif( empty( $this->where ) && empty( $this->last_indexes ) ) {
+ $this->content = array();
+ }
+
+ $return = true;
+ $this->delete = false;
+ }
+ elseif( !empty( $this->update ) ) {
+ $this->_update();
+ $this->update = [];
+ }
+ else
+ $return = false;
+ $this->commit();
+ return $this;
+ }
+
+ /**
+ * Flushes indexes they won't be reused on next action
+ *
+ * @return object $this
+ */
+ private function flush_indexes( $flush_where = false ) {
+ $this->last_indexes = array();
+ if( $flush_where )
+ $this->where = array();
+ }
+
+ /**
+ * Validates and fetch out the data for manipulation
+ *
+ * @return array $r Array of rows matching WHERE
+ */
+ private function where_result() {
+ $this->flush_indexes();
+
+ if( $this->merge == 'AND' ) {
+ return $this->where_and_result();
+ }
+ else {
+ $r = [];
+
+ // Loop through the existing values. Ge the index and row
+ foreach( $this->content as $index => $row ) {
+
+ // Make sure its array data type
+ $row = ( array ) $row;
+
+ // Loop again through each row, get columns and values
+ foreach( $row as $column => $value ) {
+ // If each of the column is provided in the where statement
+ if( in_array( $column, array_keys( $this->where ) ) ) {
+ // To be sure the where column value and existing row column value matches
+ if( $this->where[ $column ] == $row[ $column ] ) {
+ // Append all to be modified row into a array variable
+ $r[] = $row;
+
+ // Append also each row array key
+ $this->last_indexes[] = $index;
+ }
+ else
+ continue;
+ }
+ }
+ }
+ return $r;
+ }
+ }
+
+ /**
+ * Validates and fetch out the data for manipulation for AND
+ *
+ * @return array $r Array of fetched WHERE statement
+ */
+ private function where_and_result() {
+ /*
+ Validates the where statement values
+ */
+ $r = [];
+
+ // Loop through the db rows. Ge the index and row
+ foreach( $this->content as $index => $row ) {
+
+ // Make sure its array data type
+ $row = ( array ) $row;
+
+
+ //check if the row = where['col'=>'val', 'col2'=>'val2']
+ if(!array_diff($this->where,$row)) {
+ $r[] = $row;
+ // Append also each row array key
+ $this->last_indexes[] = $index;
+
+ }
+ else continue ;
+
+
+ }
+ return $r;
+ }
+
+ public function to_xml( $from, $to ) {
+ $this->from( $from );
+ if( $this->content ) {
+ $element = pathinfo( $from, PATHINFO_FILENAME );
+ $xml = '
+ <?xml version="1.0"?>
+ <' . $element . '>
+';
+
+ foreach( $this->content as $index => $value ) {
+ $xml .= '
+ <DATA>';
+ foreach( $value as $col => $val ) {
+ $xml .= sprintf( '
+ <%s>%s</%s>', $col, $val, $col );
+ }
+ $xml .= '
+ </DATA>
+ ';
+ }
+ $xml .= '</' . $element . '>';
+
+ $xml = trim( $xml );
+ file_put_contents( $to, $xml );
+ return true;
+ }
+ return false;
+ }
+
+ public function to_mysql( $from, $to, $create_table = true ) {
+ $this->from( $from );
+ if( $this->content ) {
+ $table = pathinfo( $to, PATHINFO_FILENAME );
+
+ $sql = "-- PHP-JSONDB JSON to MySQL Dump
+--\r\n\r\n";
+ if( $create_table ) {
+ $sql .= "
+-- Table Structure for `" . $table . "`
+--
+
+CREATE TABLE `" . $table . "`
+ (
+ ";
+ $first_row = ( array ) $this->content[ 0 ];
+ foreach( array_keys( $first_row ) as $column ) {
+ $s = '`' . $column . '` ' . $this->_to_mysql_type( gettype( $first_row[ $column ] ) ) ;
+ $s .= ( next( $first_row ) ? ',' : '' );
+ $sql .= $s;
+ }
+ $sql .= "
+ );\r\n";
+ }
+
+ foreach( $this->content as $values ) {
+ $values = ( array ) $values;
+ $v = array_map( function( $vv ) {
+ $vv = ( is_array( $vv ) || is_object( $vv ) ? serialize( $vv ) : $vv );
+ return "'" . addslashes( $vv ) . "'";
+ }, array_values( $values ) );
+
+ $c = array_map( function( $vv ) {
+ return "`" . $vv . "`";
+ }, array_keys( $values ) );
+ $sql .= sprintf( "INSERT INTO `%s` ( %s ) VALUES ( %s );\n", $table, implode( ', ', $c ), implode( ', ', $v ) );
+ }
+ file_put_contents( $to, $sql );
+ return true;
+ }
+ else
+ return false;
+ }
+
+ private function _to_mysql_type( $type ) {
+ if( $type == 'bool' )
+ $return = 'BOOLEAN';
+ elseif( $type == 'integer' )
+ $return = 'INT';
+ elseif( $type == 'double' )
+ $return = strtoupper( $type );
+ else
+ $return = 'VARCHAR( 255 )';
+ return $return;
+ }
+
+ public function order_by( $column, $order = self::ASC ) {
+ $this->order_by = [ $column, $order ];
+ return $this;
+ }
+
+ private function _process_order_by( $content ) {
+ if( $this->order_by && $content && in_array( $this->order_by[ 0 ], array_keys( ( array ) $content[ 0 ] ) ) ) {
+ /*
+ * Check if order by was specified
+ * Check if there's actually a result of the query
+ * Makes sure the column actually exists in the list of columns
+ */
+
+ list( $sort_column, $order_by ) = $this->order_by;
+ $sort_keys = [];
+ $sorted = [];
+
+ foreach( $content as $index => $value ) {
+ $value = ( array ) $value;
+ // Save the index and value so we can use them to sort
+ $sort_keys[ $index ] = $value[ $sort_column ];
+ }
+
+ // Let's sort!
+ if( $order_by == self::ASC ) {
+ asort( $sort_keys );
+ }
+ elseif( $order_by == self::DESC ) {
+ arsort( $sort_keys );
+ }
+
+ // We are done with sorting, lets use the sorted array indexes to pull back the original content and return new content
+ foreach( $sort_keys as $index => $value ) {
+ $sorted[ $index ] = ( array ) $content[ $index ];
+ }
+
+ $content = $sorted;
+ }
+
+ return $content;
+ }
+
+ public function get() {
+ if($this->where != null) {
+ $content = $this->where_result();
+ }
+ else
+ $content = $this->content;
+
+ if( $this->select && !in_array( '*', $this->select ) ) {
+ $r = [];
+ foreach( $content as $id => $row ) {
+ $row = ( array ) $row;
+ foreach( $row as $key => $val ) {
+ if( in_array( $key, $this->select ) ) {
+ $r[ $id ][ $key ] = $val;
+ }
+ else
+ continue;
+ }
+ }
+ $content = $r;
+ }
+
+ // Finally, lets do sorting :)
+ $content = $this->_process_order_by( $content );
+
+ $this->flush_indexes( true );
+ return $content;
+ }
+}
diff --git a/public/lib/Router.php b/public/lib/Router.php
new file mode 100644
index 0000000..c52178a
--- /dev/null
+++ b/public/lib/Router.php
@@ -0,0 +1,104 @@
+<?php
+
+class Router {
+
+ private static $routes = [];
+ private static $pathNotFound = null;
+ private static $methodNotAllowed = null;
+
+ public static function add($method, $expression, $function){
+ array_push(self::$routes, [
+ 'expression' => $expression,
+ 'function' => $function,
+ 'method' => $method
+ ]);
+ }
+
+ public static function pathNotFound($function){
+ self::$pathNotFound = $function;
+ }
+
+ public static function methodNotAllowed($function){
+ self::$methodNotAllowed = $function;
+ }
+
+ public static function run($basepath = '/'){
+
+ // Parse current url
+ $parsed_url = parse_url($_SERVER['REQUEST_URI']);//Parse Uri
+
+ if(isset($parsed_url['path'])){
+ $path = $parsed_url['path'];
+ }else{
+ $path = '/';
+ }
+
+ // Get current request method
+ $method = $_SERVER['REQUEST_METHOD'];
+
+ $path_match_found = false;
+
+ $route_match_found = false;
+
+ foreach(self::$routes as $route){
+
+ // If the method matches check the path
+
+ // Add basepath to matching string
+ if($basepath!=''&&$basepath!='/'){
+ $route['expression'] = '('.$basepath.')'.$route['expression'];
+ }
+
+ // Add 'find string start' automatically
+ $route['expression'] = '^'.$route['expression'];
+
+ // Add 'find string end' automatically
+ $route['expression'] = $route['expression'].'$';
+
+ // echo $route['expression'].'<br/>';
+
+ // Check path match
+ if(preg_match('#'.$route['expression'].'#',$path,$matches)){
+
+ $path_match_found = true;
+
+ // Check method match
+ if(strtolower($method) == strtolower($route['method'])){
+
+ array_shift($matches);// Always remove first element. This contains the whole string
+
+ if($basepath!=''&&$basepath!='/'){
+ array_shift($matches);// Remove basepath
+ }
+
+ call_user_func_array($route['function'], $matches);
+
+ $route_match_found = true;
+
+ // Do not check other routes
+ break;
+ }
+ }
+ }
+
+ // No matching route was found
+ if(!$route_match_found){
+
+ // But a matching path exists
+ if($path_match_found){
+ header("HTTP/1.0 405 Method Not Allowed");
+ if(self::$methodNotAllowed){
+ call_user_func_array(self::$methodNotAllowed, Array($path,$method));
+ }
+ }else{
+ header("HTTP/1.0 404 Not Found");
+ if(self::$pathNotFound){
+ call_user_func_array(self::$pathNotFound, Array($path));
+ }
+ }
+
+ }
+
+ }
+
+} \ No newline at end of file
diff --git a/public/lib/Sabre/FilesPlugin.php b/public/lib/Sabre/FilesPlugin.php
new file mode 100644
index 0000000..f49d891
--- /dev/null
+++ b/public/lib/Sabre/FilesPlugin.php
@@ -0,0 +1,121 @@
+<?php
+namespace FilesPlugin;
+
+class Plugin extends \Sabre\DAV\ServerPlugin {
+ protected $server = null;
+ protected $storagePath = null;
+
+
+ public function __construct($storagePath) {
+ $this->storagePath = $storagePath;
+ }
+
+
+ public function getPluginName() {
+ return 'files';
+ }
+
+ public function getPluginInfo() {
+ return [
+ 'name' => $this->getPluginName(),
+ 'description' => 'Files support',
+ 'link' => ''
+ ];
+ }
+
+ public function initialize(\Sabre\DAV\Server $server) {
+ $this->server = $server;
+
+ $this->server->on('propFind', [$this, 'propFind']);
+ }
+
+ public function propFind(\Sabre\DAV\PropFind $propFind, \Sabre\DAV\INode $node) {
+ $propFind->handle(
+ '{DAV:}getcontenttype',
+ function() use ($propFind) {
+ $mimes = new \Mimey\MimeTypes;
+
+ return $mimes->getExtension(pathinfo($propFind->getPath(), PATHINFO_EXTENSION));
+ }
+ );
+ }
+}
+
+class FilesCollection extends \Sabre\DAVACL\FS\HomeCollection {
+ public $collectionName = 'files';
+
+
+ function getChildForPrincipal(array $principalInfo) {
+ $owner = $principalInfo['uri'];
+
+ $acl = [
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $owner,
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}write',
+ 'principal' => $owner,
+ 'protected' => true,
+ ],
+ ];
+
+ list(, $principalBaseName) = \Sabre\Uri\split($owner);
+
+ $path = $this->storagePath.'/'.$principalBaseName;
+
+ if (!is_dir($path)) {
+ mkdir($path);
+ }
+
+ $out = new Directory($path, $acl, $owner);
+ $out->setRelativePath($this->storagePath);
+
+ return $out;
+ }
+}
+
+class Directory extends \Sabre\DAVACL\FS\Collection {
+ protected static $relativePath = null;
+
+
+ public function getChild($name) {
+ $path = $this->path.'/'.$name;
+
+ if (!file_exists($path)) {
+ throw new \Sabre\DAV\Exception\NotFound('File does not exist!');
+ }
+
+ if ('.' === $name || '..' === $name) {
+ throw new \Sabre\DAV\Exception\Forbidden('Permission denied to . and ..');
+ }
+
+ if (is_dir($path)) {
+ return new self($path, $this->acl, $this->owner);
+ } else {
+ return new \Sabre\DAVACL\FS\File($path, $this->acl, $this->owner);
+ }
+ }
+
+ public function setRelativePath($relativePath) {
+ self::$relativePath = $relativePath;
+ }
+
+ public static function getRelativePath() {
+ return self::$relativePath;
+ }
+
+ private function getDirSize($path){
+ $totalbytes = 0;
+ $path = realpath($path);
+
+ if($path!==false && $path!='' && file_exists($path)){
+ foreach(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)) as $object){
+ $totalbytes += $object->getSize();
+ }
+ }
+
+ return $totalbytes;
+ }
+} \ No newline at end of file
diff --git a/public/lib/Sabre/SabreAuthenticationJsonBackend.php b/public/lib/Sabre/SabreAuthenticationJsonBackend.php
new file mode 100644
index 0000000..dc786e9
--- /dev/null
+++ b/public/lib/Sabre/SabreAuthenticationJsonBackend.php
@@ -0,0 +1,34 @@
+<?php
+class SabreAuthenticationJsonBackend extends \Sabre\DAV\Auth\Backend\AbstractBasic {
+ protected $userMgr;
+
+ function __construct(UserManager $userMgr) {
+ $this->userMgr = $userMgr;
+ }
+
+ protected function validateUserPass($username, $password) {
+ $userAccount = $this->userMgr->get($username);
+
+ if (!$userAccount) {
+ return false;
+ }
+
+ if (!password_verify($password, $userAccount['password'])) {
+ return false;
+ }
+
+ if (!$userAccount['active']) {
+ return false;
+ }
+
+ return true;
+ }
+
+ function challenge(\Sabre\HTTP\RequestInterface $request, \Sabre\HTTP\ResponseInterface $response) {
+ parent::challenge($request, $response);
+
+ if ('XMLHttpRequest' === $request->getHeader('X-Requested-With')) {
+ $response->removeHeader('WWW-Authenticate');
+ }
+ }
+}
diff --git a/public/lib/Sabre/SabreCalDAVJsonBackend.php b/public/lib/Sabre/SabreCalDAVJsonBackend.php
new file mode 100644
index 0000000..ebf4dde
--- /dev/null
+++ b/public/lib/Sabre/SabreCalDAVJsonBackend.php
@@ -0,0 +1,367 @@
+<?php
+declare(strict_types=1);
+
+use Sabre\CalDAV;
+use Sabre\DAV;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\Xml\Element\Sharee;
+use Sabre\VObject;
+
+class SabreCalDAVJsonBackend extends Sabre\CalDAV\Backend\AbstractBackend implements Sabre\CalDAV\Backend\SyncSupport {
+ const MAX_DATE = '2038-01-01';
+
+ protected $calMgr;
+
+
+ public $propertyMap = [
+ 'displayname' => '{DAV:}displayname',
+ 'description' => '{urn:ietf:params:xml:ns:caldav}calendar-description',
+ 'timezone' => '{urn:ietf:params:xml:ns:caldav}calendar-timezone',
+ 'calendarorder' => '{http://apple.com/ns/ical/}calendar-order',
+ 'calendarcolor' => '{http://apple.com/ns/ical/}calendar-color',
+ ];
+
+ public function __construct (CalendarCollectionManager $calMgr) {
+ $this->calMgr = $calMgr;
+ }
+
+ public function getCalendarsForUser ($principalUri) {
+ $calendars = [];
+
+ foreach ($this->calMgr->getCollections($principalUri) as $row) {
+ $components = [];
+
+ if ($row['components']) {
+ $components = explode(',', $row['components']);
+ }
+
+ $calendar = [
+ 'id' => [(int) $row['id'], (int) $row['id']],
+ 'uri' => $row['uri'],
+ 'principaluri' => $row['principaluri'],
+ '{'.CalDAV\Plugin::NS_CALENDARSERVER.'}getctag' => 'http://sabre.io/ns/sync/'.($row['synctoken'] ? $row['synctoken'] : '0'),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ? $row['synctoken'] : '0',
+ '{'.CalDAV\Plugin::NS_CALDAV.'}supported-calendar-component-set' => new CalDAV\Xml\Property\SupportedCalendarComponentSet($components),
+ ];
+
+ foreach ($this->propertyMap as $dbName => $xmlName) {
+ $calendar[$xmlName] = $row[$dbName];
+ }
+
+ $calendars[] = $calendar;
+ }
+
+ return $calendars;
+ }
+
+ public function createCalendar ($principalUri, $calendarUri, array $properties) {
+ $sccs = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set';
+ $components = 'VEVENT,VTODO';
+
+ if (isset($properties[$sccs])) {
+ if (!($properties[$sccs] instanceof CalDAV\Xml\Property\SupportedCalendarComponentSet)) {
+ throw new DAV\Exception('The '.$sccs.' property must be of type: \Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet');
+ }
+
+ $components = implode(',', $properties[$sccs]->getValue());
+ }
+
+ $values = [
+ 'components' => $components,
+ ];
+
+ foreach ($this->propertyMap as $xmlName => $dbName) {
+ if (isset($properties[$xmlName])) {
+ $values[$dbName] = $properties[$xmlName];
+ }
+ }
+
+ $calendarId = $this->calMgr->createCollection($principalUri, $calendarUri, $values);
+
+ return [ $calendarId, $calendarId ];
+ }
+
+ public function updateCalendar ($calendarId, \Sabre\DAV\PropPatch $propPatch) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+ list($calendarId) = $calendarId;
+
+ $propPatch->handle($supportedProperties, function ($mutations) use ($calendarId) {
+ $values = [];
+
+ foreach ($this->propertyMap as $xmlName => $dbName) {
+ if (isset($mutations[$xmlName])) {
+ $values[$dbName] = $mutations[$xmlName];
+ }
+ }
+
+ $this->calMgr->updateCollection($calendarId, $values);
+ return true;
+ });
+ }
+
+ public function deleteCalendar ($calendarId) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+
+ list($calendarId) = $calendarId;
+
+ $this->calMgr->deleteCollection($calendarId);
+ }
+
+ public function getCalendarObjects ($calendarId) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+
+ list($calendarId) = $calendarId;
+
+ $result = [];
+ foreach ($this->calMgr->getObjects($calendarId) as $row) {
+ $result[] = [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'lastmodified' => (int) $row['lastmodified'],
+ 'etag' => '"'.$row['etag'].'"',
+ 'size' => (int) $row['size'],
+ 'component' => strtolower($row['componenttype']),
+ ];
+ }
+
+ return $result;
+ }
+
+ public function getCalendarObject ($calendarId, $objectUri) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+
+ list($calendarId ) = $calendarId;
+
+ $row = $this->calMgr->getObject($calendarId, $objectUri);
+
+ if (!$row) {
+ return null;
+ }
+
+ return [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'lastmodified' => (int) $row['lastmodified'],
+ 'etag' => '"'.$row['etag'].'"',
+ 'size' => (int) $row['size'],
+ 'calendardata' => $row['calendardata'],
+ 'component' => strtolower($row['componenttype']),
+ ];
+ }
+
+ public function getMultipleCalendarObjects($calendarId, array $uris) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+
+ list($calendarId) = $calendarId;
+
+ $result = [];
+
+ foreach (array_chunk($uris, 900) as $chunk) {
+ $row = $this->calMgr()->getObject($calendarId, $chunk);
+
+ $result[] = [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'lastmodified' => (int) $row['lastmodified'],
+ 'etag' => '"'.$row['etag'].'"',
+ 'size' => (int) $row['size'],
+ 'calendardata' => $row['calendardata'],
+ 'component' => strtolower($row['componenttype']),
+ ];
+ }
+
+ return $result;
+ }
+
+ public function createCalendarObject ($calendarId, $objectUri, $calendarData) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+
+ list($calendarId) = $calendarId;
+
+ return '"'.$this->calMgr->createObject($calendarId, $objectUri, $calendarData, $this->getDenormalizedData($calendarData)).'"';
+ }
+
+ public function updateCalendarObject ($calendarId, $objectUri, $calendarData) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+
+ list($calendarId) = $calendarId;
+
+ return '"'.$this->calMgr->updateObject($calendarId, $objectUri, $calendarData, $this->getDenormalizedData($calendarData)).'"';
+ }
+
+ public function deleteCalendarObject ($calendarId, $objectUri) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+
+ list($calendarId) = $calendarId;
+
+ $this->calMgr->deleteObject($calendarId, $objectUri);
+ }
+
+ public function calendarQuery ($calendarId, array $filters) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+
+ list($calendarId) = $calendarId;
+
+ $componentType = NULL;
+ $requirePostFilter = true;
+ $timeRange = NULL;
+
+ // if no filters were specified, we don't need to filter after a query
+ if (!$filters['prop-filters'] && !$filters['comp-filters']) {
+ $requirePostFilter = false;
+ }
+
+ // Figuring out if there's a component filter
+ if (count($filters['comp-filters']) > 0 && !$filters['comp-filters'][0]['is-not-defined']) {
+ $componentType = $filters['comp-filters'][0]['name'];
+
+ // Checking if we need post-filters
+ if (!$filters['prop-filters'] && !$filters['comp-filters'][0]['comp-filters'] && !$filters['comp-filters'][0]['time-range'] && !$filters['comp-filters'][0]['prop-filters']) {
+ $requirePostFilter = false;
+ }
+ // There was a time-range filter
+ if ('VEVENT' == $componentType && isset($filters['comp-filters'][0]['time-range'])) {
+ $timeRange = $filters['comp-filters'][0]['time-range'];
+
+ // If start time OR the end time is not specified, we can do a
+ // 100% accurate mysql query.
+ if (!$filters['prop-filters'] && !$filters['comp-filters'][0]['comp-filters'] && !$filters['comp-filters'][0]['prop-filters'] && (!$timeRange['start'] || !$timeRange['end'])) {
+ $requirePostFilter = false;
+ }
+ }
+ }
+
+ $entries = $this->calMgr->getObjects($calendarId);
+ foreach ($entries as $key => $entry) {
+ if ($componentType && $componentType !== $entry['componenttype']) unset($entries[$key]); continue;
+
+ if ($timeRange) {
+ if ($timeRange['start'] && $entry['lastoccurence'] > $timeRange['start']->getTimeStamp()) unset($entries[$key]); continue;
+ if ($timeRange['end'] && $entry['firstoccurence'] < $timeRange['end']->getTimeStamp()) unset($entries[$key]); continue;
+ }
+ }
+
+ $result = [];
+ foreach ($entries as $row) {
+ if ($requirePostFilter) {
+ $row['calendarid'] = [$calendarId, $calendarId];
+
+ if (!$this->validateFilterForObject($row, $filters)) {
+ continue;
+ }
+ }
+
+ $result[] = $row['uri'];
+ }
+
+ return $result;
+ }
+
+ public function getCalendarObjectByUID ($principalUri, $uid) {
+ return $this->calMgr->getUriByUID($principalUri, $uid);
+ }
+
+ public function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null) {
+ if (!is_array($calendarId)) {
+ throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
+ }
+
+ list($calendarId) = $calendarId;
+
+ return $this->calMgr->getChanges($calendarid, $syncToken, $syncLevel);
+ }
+
+
+
+ protected function getDenormalizedData ($calendarData) {
+ $vObject = VObject\Reader::read($calendarData);
+ $componentType = null;
+ $component = null;
+ $firstOccurence = null;
+ $lastOccurence = null;
+ $uid = null;
+
+ foreach ($vObject->getComponents() as $component) {
+ if ('VTIMEZONE' !== $component->name) {
+ $componentType = $component->name;
+ $uid = (string) $component->UID;
+ break;
+ }
+ }
+
+ if (!$componentType) {
+ throw new \Sabre\DAV\Exception\BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component');
+ }
+
+ if ('VEVENT' === $componentType) {
+ $firstOccurence = $component->DTSTART->getDateTime()->getTimeStamp();
+ // Finding the last occurence is a bit harder
+ if (!isset($component->RRULE)) {
+ if (isset($component->DTEND)) {
+ $lastOccurence = $component->DTEND->getDateTime()->getTimeStamp();
+ } elseif (isset($component->DURATION)) {
+ $endDate = clone $component->DTSTART->getDateTime();
+ $endDate = $endDate->add(VObject\DateTimeParser::parse($component->DURATION->getValue()));
+ $lastOccurence = $endDate->getTimeStamp();
+ } elseif (!$component->DTSTART->hasTime()) {
+ $endDate = clone $component->DTSTART->getDateTime();
+ $endDate = $endDate->modify('+1 day');
+ $lastOccurence = $endDate->getTimeStamp();
+ } else {
+ $lastOccurence = $firstOccurence;
+ }
+ } else {
+ $it = new VObject\Recur\EventIterator($vObject, (string) $component->UID);
+ $maxDate = new \DateTime(self::MAX_DATE);
+ if ($it->isInfinite()) {
+ $lastOccurence = $maxDate->getTimeStamp();
+ } else {
+ $end = $it->getDtEnd();
+ while ($it->valid() && $end < $maxDate) {
+ $end = $it->getDtEnd();
+ $it->next();
+ }
+ $lastOccurence = $end->getTimeStamp();
+ }
+ }
+
+ // Ensure Occurence values are positive
+ if ($firstOccurence < 0) {
+ $firstOccurence = 0;
+ }
+ if ($lastOccurence < 0) {
+ $lastOccurence = 0;
+ }
+ }
+
+ // Destroy circular references to PHP will GC the object.
+ $vObject->destroy();
+
+ return [
+ 'etag' => md5($calendarData),
+ 'size' => strlen($calendarData),
+ 'componenttype' => $componentType,
+ 'firstoccurence' => $firstOccurence,
+ 'lastoccurence' => $lastOccurence,
+ 'uid' => $uid,
+ ];
+ }
+}
diff --git a/public/lib/Sabre/SabreCardDAVJsonBackend.php b/public/lib/Sabre/SabreCardDAVJsonBackend.php
new file mode 100644
index 0000000..d870114
--- /dev/null
+++ b/public/lib/Sabre/SabreCardDAVJsonBackend.php
@@ -0,0 +1,127 @@
+<?php
+declare(strict_types=1);
+
+use Sabre\CardDAV;
+use Sabre\DAV;
+
+class SabreCardDAVJsonBackend extends Sabre\CardDAV\Backend\AbstractBackend implements Sabre\CardDAV\Backend\SyncSupport {
+ protected $cardMgr;
+
+ public function __construct(AddressbookCollectionManager $cardMgr) {
+ $this->cardMgr = $cardMgr;
+ }
+
+ public function getAddressBooksForUser ($principalUri) {
+ $addressBooks = [];
+
+ foreach ($this->cardMgr->getCollections($principalUri) as $row) {
+ $addressBooks[] = [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'principaluri' => $row['principaluri'],
+ '{DAV:}displayname' => $row['displayname'],
+ '{'.CardDAV\Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
+ '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'],
+ ];
+ }
+
+ return $addressBooks;
+ }
+
+ public function updateAddressBook ($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
+ $supportedProperties = [
+ '{DAV:}displayname',
+ '{'.CardDAV\Plugin::NS_CARDDAV.'}addressbook-description',
+ ];
+
+ $propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
+ $values = [];
+
+ foreach ($mutations as $property => $newValue) {
+ switch ($property) {
+ case '{DAV:}displayname':
+ $values['displayname'] = $newValue;
+ break;
+ case '{'.CardDAV\Plugin::NS_CARDDAV.'}addressbook-description':
+ $values['description'] = $newValue;
+ break;
+ }
+ }
+
+ $this->cardMgr->updateCollection($addressBookId, $values);
+ return true;
+ });
+ }
+
+ public function createAddressBook ($principalUri, $url, array $properties) {
+ $displayname = null;
+ $description = null;
+
+ foreach ($properties as $property => $newValue) {
+ switch ($property) {
+ case '{DAV:}displayname':
+ $displayname = $newValue;
+ break;
+ case '{'.CardDAV\Plugin::NS_CARDDAV.'}addressbook-description':
+ $description = $newValue;
+ break;
+ default:
+ throw new DAV\Exception\BadRequest('Unknown property: '.$property);
+ }
+ }
+
+ return $this->cardMgr->createCollection($principalUri, $url, $displayname, $description);
+ }
+
+ public function deleteAddressBook ($addressBookId) {
+ $this->cardMgr->deleteCollection($addressBookId);
+ }
+
+ public function getCards ($addressbookId) {
+ $objects = $this->cardMgr->getObjects($addressbookId);
+
+ foreach ($objects as $key => $object) {
+ $objects[$key]['etag'] = '"'.$object['etag'].'"';
+ }
+
+ return $objects;
+ }
+
+ public function getCard ($addressBookId, $cardUri) {
+ $object = $this->cardMgr->getObject($addressBookId, $cardUri);
+ if (isset($object['etag'])) $object['etag'] = '"'.$object['etag'].'"';
+
+ return $object;
+ }
+
+ public function getMultipleCards ($addressBookId, array $uris) {
+ $result = [];
+
+ foreach ($uris as $uri) {
+ $result[] = $this->getCard($addressBookId, $uri);
+ }
+
+ return $result;
+ }
+
+ public function createCard ($addressBookId, $uri, $cardData) {
+ $etag = $this->cardMgr->createObject($addressBookId, $uri, $cardData);
+
+ return '"'.$etag.'"';
+ }
+
+ public function updateCard ($addressBookId, $uri, $cardData) {
+ $etag = $this->cardMgr->updateObject($addressBookId, $uri, $cardData);
+
+ return '"'.$etag.'"';
+ }
+
+ public function deleteCard ($addressBookId, $uri) {
+ return $this->cardMgr->deleteObject($addressBookId, $uri);
+ }
+
+ public function getChangesForAddressBook ($addressBookId, $syncToken, $syncLevel, $limit = null) {
+ return $this->cardMgr->getChanges($addressBookId, $syncToken, $syncLevel);
+ }
+}
diff --git a/public/lib/Sabre/SabrePrincipalJsonBackend.php b/public/lib/Sabre/SabrePrincipalJsonBackend.php
new file mode 100644
index 0000000..e40248f
--- /dev/null
+++ b/public/lib/Sabre/SabrePrincipalJsonBackend.php
@@ -0,0 +1,201 @@
+<?php
+declare(strict_types=1);
+
+use Sabre\DAV;
+use Sabre\DAV\MkCol;
+use Sabre\Uri;
+
+class SabrePrincipalJsonBackend extends Sabre\DAVACL\PrincipalBackend\AbstractBackend {
+ protected $userMgr;
+
+ /**
+ * Sets up the backend.
+ *
+ * @param \PDO $pdo
+ */
+ public function __construct (UserManager $userMgr) {
+ $this->userMgr = $userMgr;
+ }
+
+ /**
+ * Returns a list of principals based on a prefix.
+ *
+ * This prefix will often contain something like 'principals'. You are only
+ * expected to return principals that are in this base path.
+ *
+ * You are expected to return at least a 'uri' for every user, you can
+ * return any additional properties if you wish so. Common properties are:
+ * {DAV:}displayname
+ * {http://sabredav.org/ns}email-address - This is a custom SabreDAV
+ * field that's actualy injected in a number of other properties. If
+ * you have an email address, use this property.
+ *
+ * @param string $prefixPath
+ *
+ * @return array
+ */
+ public function getPrincipalsByPrefix ($prefixPath) {
+ if ($prefixPath !== 'principals') {
+ return [];
+ }
+
+ $principals = [];
+
+ $users = $this->userMgr->getAll();
+
+ foreach ($users as $user) {
+ if ($user->id == 1) continue;
+
+ $principals[] = [
+ 'uri' => 'principals/'.$user->username,
+ '{DAV:}displayname' => $user->username,
+ ];
+ }
+
+ return $principals;
+ }
+
+ /**
+ * Returns a specific principal, specified by it's path.
+ * The returned structure should be the exact same as from
+ * getPrincipalsByPrefix.
+ *
+ * @param string $path
+ *
+ * @return array
+ */
+ public function getPrincipalByPath ($path) {
+ list($prefix,$username) = explode('/', $path);
+
+ if ($prefix !== 'principals') return;
+
+ $user = $this->userMgr->get($username);
+
+ if (!$user) return;
+
+ return [
+ 'id' => $user['id'],
+ 'uri' => 'principals/'.$user['username'],
+ '{DAV:}displayname' => $user['username'],
+ ];
+ }
+
+ /**
+ * Updates one ore more webdav properties on a principal.
+ *
+ * The list of mutations is stored in a Sabre\DAV\PropPatch object.
+ * To do the actual updates, you must tell this object which properties
+ * you're going to process with the handle() method.
+ *
+ * Calling the handle method is like telling the PropPatch object "I
+ * promise I can handle updating this property".
+ *
+ * Read the PropPatch documentation for more info and examples.
+ *
+ * @param string $path
+ * @param DAV\PropPatch $propPatch
+ */
+ public function updatePrincipal ($path, DAV\PropPatch $propPatch) {
+ return false;
+ }
+
+ /**
+ * This method is used to search for principals matching a set of
+ * properties.
+ *
+ * This search is specifically used by RFC3744's principal-property-search
+ * REPORT.
+ *
+ * The actual search should be a unicode-non-case-sensitive search. The
+ * keys in searchProperties are the WebDAV property names, while the values
+ * are the property values to search on.
+ *
+ * By default, if multiple properties are submitted to this method, the
+ * various properties should be combined with 'AND'. If $test is set to
+ * 'anyof', it should be combined using 'OR'.
+ *
+ * This method should simply return an array with full principal uri's.
+ *
+ * If somebody attempted to search on a property the backend does not
+ * support, you should simply return 0 results.
+ *
+ * You can also just return 0 results if you choose to not support
+ * searching at all, but keep in mind that this may stop certain features
+ * from working.
+ *
+ * @param string $prefixPath
+ * @param array $searchProperties
+ * @param string $test
+ *
+ * @return array
+ */
+ public function searchPrincipals ($prefixPath, array $searchProperties, $test = 'allof') {
+ return [];
+ }
+
+ /**
+ * Finds a principal by its URI.
+ *
+ * This method may receive any type of uri, but mailto: addresses will be
+ * the most common.
+ *
+ * Implementation of this API is optional. It is currently used by the
+ * CalDAV system to find principals based on their email addresses. If this
+ * API is not implemented, some features may not work correctly.
+ *
+ * This method must return a relative principal path, or null, if the
+ * principal was not found or you refuse to find it.
+ *
+ * @param string $uri
+ * @param string $principalPrefix
+ *
+ * @return string
+ */
+ public function findByUri ($uri, $principalPrefix) {
+ return null;
+ }
+
+ /**
+ * Returns the list of members for a group-principal.
+ *
+ * @param string $principal
+ *
+ * @return array
+ */
+ public function getGroupMemberSet ($principal) {
+ return [];
+ }
+
+ /**
+ * Returns the list of groups a principal is a member of.
+ *
+ * @param string $principal
+ *
+ * @return array
+ */
+ public function getGroupMembership ($principal) {
+ return [];
+ }
+
+ /**
+ * Updates the list of group members for a group principal.
+ *
+ * The principals should be passed as a list of uri's.
+ *
+ * @param string $principal
+ * @param array $members
+ */
+ public function setGroupMemberSet ($principal, array $members) {}
+
+ /**
+ * Creates a new principal.
+ *
+ * This method receives a full path for the new principal. The mkCol object
+ * contains any additional webdav properties specified during the creation
+ * of the principal.
+ *
+ * @param string $path
+ * @param MkCol $mkCol
+ */
+ public function createPrincipal ($path, MkCol $mkCol) {}
+}
diff --git a/public/lib/Sabre/SabrePropertyStorageJsonBackend.php b/public/lib/Sabre/SabrePropertyStorageJsonBackend.php
new file mode 100644
index 0000000..fe4252f
--- /dev/null
+++ b/public/lib/Sabre/SabrePropertyStorageJsonBackend.php
@@ -0,0 +1,194 @@
+<?php
+declare(strict_types=1);
+
+use Sabre\DAV\PropFind;
+use Sabre\DAV\PropPatch;
+use Sabre\DAV\Xml\Property\Complex;
+
+class SabrePropertyStorageJsonBackend implements Sabre\DAV\PropertyStorage\Backend\BackendInterface {
+ const VT_STRING = 1;
+ const VT_XML = 2;
+ const VT_OBJECT = 3;
+
+ protected $db;
+
+ public function __construct (JSONDB $db) {
+ $this->db = $db;
+ }
+
+ /**
+ * Fetches properties for a path.
+ *
+ * This method received a PropFind object, which contains all the
+ * information about the properties that need to be fetched.
+ *
+ * Usually you would just want to call 'get404Properties' on this object,
+ * as this will give you the _exact_ list of properties that need to be
+ * fetched, and haven't yet.
+ *
+ * However, you can also support the 'allprops' property here. In that
+ * case, you should check for $propFind->isAllProps().
+ *
+ * @param string $path
+ * @param PropFind $propFind
+ */
+ public function propFind($path, PropFind $propFind)
+ {
+ if (!$propFind->isAllProps() && 0 === count($propFind->get404Properties())) {
+ return;
+ }
+
+ $propertys = $this->db->select('*')
+ ->from('propertystorage.json')
+ ->where(['path' => $path])
+ ->get();
+
+ foreach ($propertys as $property) {
+ if ('resource' === gettype($property['value'])) {
+ $property['value'] = stream_get_contents($property['value']);
+ }
+
+ switch ($property['valuetype']) {
+ case null:
+ case self::VT_STRING:
+ $propFind->set($property['name'], $property['value']);
+ break;
+ case self::VT_XML:
+ $propFind->set($property['name'], new Complex($property['value']));
+ break;
+ case self::VT_OBJECT:
+ $propFind->set($property['name'], unserialize($property['value']));
+ break;
+ }
+ }
+ }
+
+ /**
+ * Updates properties for a path.
+ *
+ * This method received a PropPatch object, which contains all the
+ * information about the update.
+ *
+ * Usually you would want to call 'handleRemaining' on this object, to get;
+ * a list of all properties that need to be stored.
+ *
+ * @param string $path
+ * @param PropPatch $propPatch
+ */
+ public function propPatch ($path, PropPatch $propPatch) {
+ $propPatch->handleRemaining(function ($properties) use ($path) {
+ foreach ($properties as $name => $value) {
+ if (!is_null($value)) {
+ if (is_scalar($value)) {
+ $valueType = self::VT_STRING;
+ } elseif ($value instanceof Complex) {
+ $valueType = self::VT_XML;
+ $value = $value->getXml();
+ } else {
+ $valueType = self::VT_OBJECT;
+ $value = serialize($value);
+ }
+
+ $result = $this->db->select('*')
+ ->from('propertystorage.json')
+ ->where(['path' => $path, 'name' => $name])
+ ->get();
+ if (!$result) {
+ $this->db->insert('propertystorage.json', [
+ 'path' => $path,
+ 'name' => $name,
+ 'valuetype' => $valueType,
+ 'value' => $value,
+ ]);
+ } else {
+ $this->db->update(['valuetype' => $valueType, 'value' => $value])
+ ->from('propertystorage.json')
+ ->where(['path' => $path, 'name' => $name])
+ ->trigger();
+ }
+
+ } else {
+ $this->db->delete()
+ ->from('propertystorage.json')
+ ->where(['path' => $path, 'name' => $name])
+ ->trigger();
+ }
+ }
+
+ return true;
+ });
+ }
+
+ /**
+ * This method is called after a node is deleted.
+ *
+ * This allows a backend to clean up all associated properties.
+ *
+ * The delete method will get called once for the deletion of an entire
+ * tree.
+ *
+ * @param string $path
+ */
+ public function delete ($path) {
+ $paths = [];
+
+ $results = $this->db->select('path')
+ ->from('propertystorage.json')
+ ->get();
+
+ foreach ($results as $result) {
+ if (!Helpers::startsWith($path, $result['path'])) continue;
+
+ $paths[] = $result['path'];
+ }
+
+ foreach ($paths as $path) {
+ $this->db->delete()
+ ->from('propertystorage.json')
+ ->where(['path' => $path])
+ ->trigger();
+ }
+ }
+
+ /**
+ * This method is called after a successful MOVE.
+ *
+ * This should be used to migrate all properties from one path to another.
+ * Note that entire collections may be moved, so ensure that all properties
+ * for children are also moved along.
+ *
+ * @param string $source
+ * @param string $destination
+ */
+ public function move ($source, $destination) {
+ $paths = [];
+
+ $results = $this->db->select('path')
+ ->from('propertystorage.json')
+ ->get();
+
+ foreach ($results as $result) {
+ if (!Helpers::startsWith($source, $result['path'])) continue;
+
+ $paths[] = $result['path'];
+ }
+
+ foreach ($paths as $path) {
+ if ($row['path'] !== $source && 0 !== strpos($row['path'], $source.'/')) {
+ continue;
+ }
+
+ $trailingPart = substr($row['path'], strlen($source) + 1);
+ $newPath = $destination;
+ if ($trailingPart) {
+ $newPath .= '/'.$trailingPart;
+ }
+ $update->execute([$newPath, $row['id']]);
+
+ $this->db->update(['path' => $newPath])
+ ->from('propertystorage.json')
+ ->where(['path' => $path])
+ ->trigger();
+ }
+ }
+}
diff --git a/public/lib/Template.php b/public/lib/Template.php
new file mode 100644
index 0000000..0eaefdd
--- /dev/null
+++ b/public/lib/Template.php
@@ -0,0 +1,161 @@
+<?php
+
+class Template {
+ public $vars = [];
+ public $blocks = [];
+ private $pagevars = [];
+ private $tpl_path = NULL;
+ private $cache_path = NULL;
+
+ public function __construct ($tpl_path, array $pagevars) {
+ if(!file_exists($tpl_path)){
+ throw new Exception('Error templates folder not found.');
+ } else {
+ $this->tpl_path = $tpl_path;
+ }
+
+ $this->pagevars = $pagevars;
+ }
+
+ public function assign ($vars, $value = null) {
+ if (is_array($vars)) {
+ $this->vars = array_merge($this->vars, $vars);
+ } else if ($value !== null) {
+ $this->vars[$vars] = $value;
+ }
+ }
+
+ public function blockAssign ($name, $array) {
+ $this->blocks[$name][] = (array)$array;
+ }
+
+ private function compileVars ($var) {
+ $newvar = $this->compileVar($var[1]);
+ return "<?php echo isset(" . $newvar . ") ? " . $newvar . " : '{" . $var[1] . "}' ?>";
+ }
+
+ private function compileVar ($var) {
+ if (strpos($var, '.') === false) {
+ $var = '$this->vars[\'' . $var . '\']';
+ } else {
+ $vars = explode('.', $var);
+ if (!isset($this->blocks[$vars[0]]) && isset($this->vars[$var[0]]) && gettype($this->vars[$var[0]]) == 'array') {
+ $var = '$this->vars[\'' . $vars[0] . '\'][\'' . $vars[1] . '\']';
+ } else {
+ $var = preg_replace("#(.*)\.(.*)#", "\$_$1['$2']", $var);
+ }
+ }
+ return $var;
+ }
+
+ private function compileTags ($match) {
+ switch ($match[1]) {
+ case 'INCLUDE':
+ return "<?php echo \$this->compile('" . $match[2] . "'); ?>";
+ break;
+
+ case 'INCLUDEPHP':
+ return "<?php echo include(" . PATH . $match[2] . "'); ?>";
+ break;
+
+ case 'IF':
+ return $this->compileIf($match[2], false);
+ break;
+
+ case 'ELSEIF':
+ return $this->compileIf($match[2], true);
+ break;
+
+ case 'ELSE':
+ return "<?php } else { ?>";
+ break;
+
+ case 'ENDIF':
+ return "<?php } ?>";
+ break;
+
+ case 'BEGIN':
+ return "<?php if (isset(\$this->blocks['" . $match[2] . "'])) { foreach (\$this->blocks['" . $match[2] . "'] as \$_" . $match[2] . ") { ?>";
+ break;
+
+ case 'BEGINELSE':
+ return "<?php } } else { { ?>";
+ break;
+
+ case 'END':
+ return "<?php } } ?>";
+ break;
+ }
+ }
+
+ private function compileIf ($code, $elseif) {
+ $ex = explode(' ', trim($code));
+ $code = '';
+
+ foreach ($ex as $value) {
+ $chars = strtolower($value);
+
+ switch ($chars) {
+ case 'and':
+ case '&&':
+ case 'or':
+ case '||':
+ case '==':
+ case '!=':
+ case '!==':
+ case '>':
+ case '<':
+ case '>=':
+ case '<=':
+ case '0':
+ case is_numeric($value):
+ $code .= $value;
+ break;
+
+ case 'not':
+ $code .= '!';
+ break;
+
+ default:
+ if (preg_match('/^[A-Za-z0-9_\-\.]+$/i', $value)) {
+ $var = $this->compileVar($value);
+ $code .= "(isset(" . $var . ") ? " . $var . " : '')";
+ } else {
+ $code .= '\'' . preg_replace("#(\\\\|\'|\")#", '', $value) . '\'';
+ }
+ break;
+ }
+ $code .= ' ';
+ }
+
+ return '<?php ' . (($elseif) ? '} else ' : '') . 'if (' . trim($code) . ") { ?>";
+ }
+
+ private function compile ($file) {
+ $abs_file = $this->tpl_path.'/'.$file;
+
+ $tpl = file_get_contents($abs_file);
+ $tpl = preg_replace("#<\?(.*)\?>#", '', $tpl);
+ $tpl = preg_replace_callback("#<!-- ([A-Z]+) (.*)? ?-->#U", array($this, 'compileTags'), $tpl);
+ $tpl = preg_replace_callback("#{([A-Za-z0-9_\-.]+)}#U", array($this, 'compileVars'), $tpl);
+
+ if (eval(' ?>'.$tpl.'<?php ') === false) {
+ $this->error();
+ }
+ }
+
+ public function error () {
+ exit('Fehler im Template!');
+ }
+
+ public function render ($file, $data = NULL) {
+ $this->assign($this->pagevars);
+
+ if ($data !== NULL) {
+ $this->assign($data);
+ }
+
+ $this->compile($file.'.tpl');
+ exit();
+ }
+} \ No newline at end of file
diff --git a/public/lib/UserManager.php b/public/lib/UserManager.php
new file mode 100644
index 0000000..ab45abf
--- /dev/null
+++ b/public/lib/UserManager.php
@@ -0,0 +1,141 @@
+<?php
+
+class UserManager {
+ private $cookie_lifetime = 2678400;
+ private $db;
+
+ public $userAccount;
+
+ public function __construct (JSONDB $database) {
+ $this->db = $database;
+
+ if (!empty($_SESSION['username'])) {
+ $this->userAccount = $this->get($_SESSION['username']);
+ }
+ }
+
+ public function isLoggedIn () {
+ return (!$this->userAccount) ? false : true;
+ }
+
+ public function checkLoggedIn () {
+ if (!$this->isLoggedIn()) {
+ header("Location: /login");
+ exit();
+ }
+ }
+
+ public function isAdmin () {
+ if (!$this->userAccount) return false;
+ return ($this->userAccount['id'] !== 1) ? false : true;
+ }
+
+ public function getLoggedInAccount () {
+ return $this->userAccount;
+ }
+
+ public function exists ($username) {
+ return (!$this->get($username)) ? false : true;
+ }
+
+ public function checkLogin ($username, $password) {
+ $userAccount = $this->get($username);
+
+ if (!$userAccount || !password_verify($password, $userAccount['password'])) {
+ throw new Exception('Account unknown or password wrong.');
+ }
+
+ if (!$userAccount['active']) {
+ throw new Exception('This account is disabled.');
+ }
+
+ $_SESSION['username'] = $userAccount['username'];
+
+ $this->userAccount = $userAccount;
+
+ return true;
+ }
+
+ public function logout () {
+ $this->userAccount = null;
+ session_destroy();
+
+ return true;
+ }
+
+ public function get ($username) {
+ $result = $this->db->select('*')
+ ->from('users.json')
+ ->where(['username' => $username])
+ ->get();
+
+ if (!isset($result[0])) return false;
+
+ return $result[0];
+ }
+
+ public function getAll () {
+ $result = $this->db->select('*')
+ ->from('users.json')
+ ->get();
+
+ return $result;
+ }
+
+ public function getHighestUserId () {
+ $data = $this->db->select('id')
+ ->from('users.json')
+ ->order_by('id', JSONDB::ASC)
+ ->get();
+
+ return end($data)['id'];
+ }
+
+ public function updatePassword ($username, $password) {
+ if (!$this->exists($username)) throw new Exception('User doesn\'t exist!');
+
+ $this->db->update(['password' => password_hash($password, PASSWORD_DEFAULT)])
+ ->from('users.json')
+ ->where(['username' => $username])
+ ->trigger();
+ }
+
+ public function create ($username, $password, $active = true) {
+ if ($this->exists($username)) throw new Exception('This username is already taken.');
+// if(!preg_match('/^[\w-]+$/', $username)) throw new Exception('URI contains not allowed characters.');
+
+ $this->db->insert('users.json', [
+ 'id' => $this->getHighestUserId()+1,
+ 'username' => $username,
+ 'password' => password_hash($password, PASSWORD_DEFAULT),
+ 'active' => $active,
+ ]);
+ }
+
+ public function enable ($username) {
+ if (!$this->exists($username)) throw new Exception('User doesn\'t exist!');
+
+ $this->db->update(['active' => true])
+ ->from('users.json')
+ ->where(['username' => $username])
+ ->trigger();
+ }
+
+ public function disable ($username) {
+ if (!$this->exists($username)) throw new Exception('User doesn\'t exist!');
+
+ $this->db->update(['active' => false])
+ ->from('users.json')
+ ->where(['username' => $username])
+ ->trigger();
+ }
+
+ public function delete ($username) {
+ if (!$this->exists($username)) throw new Exception('User doesn\'t exist!');
+
+ $this->db->delete()
+ ->from('users.json')
+ ->where(['username' => $username])
+ ->trigger();
+ }
+} \ No newline at end of file
diff --git a/public/main.css b/public/main.css
new file mode 100644
index 0000000..94b7c57
--- /dev/null
+++ b/public/main.css
@@ -0,0 +1,6 @@
+.button-small {
+ font-size: .8rem;
+ height: 2.8rem;
+ line-height: 2.8rem;
+ padding: 0 1.5rem;
+} \ No newline at end of file
diff --git a/public/milligram.min.css b/public/milligram.min.css
new file mode 100644
index 0000000..85f877b
--- /dev/null
+++ b/public/milligram.min.css
@@ -0,0 +1,11 @@
+/*!
+ * Milligram v1.3.0
+ * https://milligram.github.io
+ *
+ * Copyright (c) 2017 CJ Patoilo
+ * Licensed under the MIT license
+ */
+
+*,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#606c76;font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#9b4dca;border:0.1rem solid #9b4dca;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#9b4dca;border-color:#9b4dca}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#9b4dca}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#9b4dca}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#9b4dca}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#9b4dca}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #9b4dca;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border-color:#9b4dca;outline:0}select{background:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#9b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>')}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.container{margin:0 auto;max-width:112.0rem;padding:0 2.0rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{-ms-grid-row-align:center;align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#9b4dca;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right}
+
+/*# sourceMappingURL=milligram.min.css.map */ \ No newline at end of file
diff --git a/public/template/footer.tpl b/public/template/footer.tpl
new file mode 100644
index 0000000..fe31377
--- /dev/null
+++ b/public/template/footer.tpl
@@ -0,0 +1,6 @@
+ </div>
+ </div>
+ </div>
+ </main>
+ </body>
+</html>
diff --git a/public/template/header.tpl b/public/template/header.tpl
new file mode 100644
index 0000000..af914a9
--- /dev/null
+++ b/public/template/header.tpl
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{PAGE} - TinyDAV</title>
+ <link rel="icon" href="images/favicon.ico">
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
+ <link rel="stylesheet" href="milligram.min.css">
+ <link rel="stylesheet" href="main.css">
+ </head>
+ <body>
+ <main class="wrapper">
+ <div class="container">
+ <div class="row">
+ <div class="column column-50 column-offset-25">
diff --git a/public/template/login.tpl b/public/template/login.tpl
new file mode 100644
index 0000000..01a8cf2
--- /dev/null
+++ b/public/template/login.tpl
@@ -0,0 +1,17 @@
+<!-- INCLUDE header.tpl -->
+
+<h5 class="title">TinyDAV</h5>
+
+<form method="post">
+ <fieldset>
+ <label for="username">User:</label>
+ <input type="text" name="username" id="username">
+
+ <label for="password">Password:</label>
+ <input type="password" name="password" id="password">
+
+ <input class="button-primary" type="submit" value="Login">
+ </fieldset>
+</form>
+
+<!-- INCLUDE footer.tpl --> \ No newline at end of file
diff --git a/public/template/message.tpl b/public/template/message.tpl
new file mode 100644
index 0000000..a137ad7
--- /dev/null
+++ b/public/template/message.tpl
@@ -0,0 +1,7 @@
+<!-- INCLUDE header.tpl -->
+
+<h5 class="title">TinyDAV</h5>
+
+<p>{MSG}</p>
+
+<!-- INCLUDE footer.tpl --> \ No newline at end of file
diff --git a/public/template/overview.tpl b/public/template/overview.tpl
new file mode 100644
index 0000000..2d65ca0
--- /dev/null
+++ b/public/template/overview.tpl
@@ -0,0 +1,142 @@
+<!-- INCLUDE header.tpl -->
+
+<div class="clearfix">
+ <div class="float-left">
+ <h4 class="title">TinyDAV</h4>
+ </div>
+ <div class="float-right">
+ <a class="button button-small" href="/logout">logout</a>
+ </div>
+</div>
+
+<!-- IF not IS_ADMIN -->
+
+<h5>Calendars</h5>
+<table>
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>URI</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+
+ <tbody>
+
+ <!-- BEGIN calendars -->
+ <tr>
+ <td>{calendars.DISPLAYNAME}</td>
+ <td>https://{PAGE_URL}/dav/addressbooks/{USERNAME}/{calendars.URI}</td>
+ <td><a href="/update?type=calendar&id={calendars.ID}">edit</a> - <a href="/dav/addressbooks/{USERNAME}/{calendars.URI}?export">export</a></td>
+ </tr>
+ <!-- END calendars -->
+
+ </tbody>
+</table>
+
+<h5>Addressbooks</h5>
+<table>
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>URI</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+
+ <tbody>
+
+ <!-- BEGIN addressbooks -->
+ <tr>
+ <td>{addressbooks.DISPLAYNAME}</td>
+ <td>https://{PAGE_URL}/dav/addressbooks/{USERNAME}/{addressbooks.URI}</td>
+ <td><a href="/update?type=addressbook&id={addressbooks.ID}">edit</a> - <a href="/dav/addressbooks/{USERNAME}/{addressbooks.URI}?export">export</a></td>
+ </tr>
+ <!-- END addressbooks -->
+
+ </tbody>
+</table>
+
+<h5>New</h5>
+<form method="post" action="/create">
+ <fieldset>
+ <label for="type">Type</label>
+ <select id="type" name="type">
+ <option value="calendar">Calendar</option>
+ <option value="addressbook">Addressbook</option>
+ </select>
+
+ <label for="displayname">Displayname:</label>
+ <input type="text" name="displayname" id="displayname">
+
+ <label for="displayname">URI: (optional)</label>
+ <input type="text" name="uri" id="uri">
+
+ <input type="hidden" name="username" value="{USERNAME}">
+ <input class="button-primary" type="submit" value="create">
+ </fieldset>
+</form>
+
+<!-- ELSE -->
+
+<h5>Users</h5>
+<table>
+ <thead>
+ <tr>
+ <th>Username</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+
+ <!-- BEGIN users -->
+ <tr>
+ <td>{users.USERNAME}</td>
+ <!-- IF users.ID == 1 -->
+
+ <td>admin can't be edited</td>
+
+ <!-- ELSE -->
+
+ <td><a href="/update?type=user&username={users.USERNAME}">edit</a>
+
+ <!-- ENDIF -->
+ </tr>
+ <!-- END users -->
+
+ </tbody>
+</table>
+
+<h5>New user</h5>
+<form method="post" action="/create">
+ <fieldset>
+ <label for="username">Username:</label>
+ <input type="text" name="username" id="username" required>
+
+ <label for="password">Password:</label>
+ <input type="password" name="password" id="password" required>
+
+ <input type="hidden" name="type" value="user">
+ <input class="button-primary" type="submit" value="create">
+ </fieldset>
+</form>
+
+<!-- ENDIF -->
+
+<h5>Change password</h5>
+
+<form method="post" action="/update">
+ <fieldset>
+ <label for="password1">New password:</label>
+ <input type="password" name="password1" id="password1" required>
+
+ <label for="password1">Repeat new password:</label>
+ <input type="password" name="password2" id="password2" required>
+
+ <input type="hidden" name="type" value="password">
+ <input type="hidden" name="username" value="{USERNAME}">
+ <input class="button-primary" type="submit" value="update password">
+ </fieldset>
+</form>
+
+<!-- INCLUDE footer.tpl --> \ No newline at end of file
diff --git a/public/template/update.tpl b/public/template/update.tpl
new file mode 100644
index 0000000..0a1254c
--- /dev/null
+++ b/public/template/update.tpl
@@ -0,0 +1,138 @@
+<!-- INCLUDE header.tpl -->
+
+<div class="clearfix">
+ <div class="float-left">
+ <h4 class="title">TinyDAV</h4>
+ </div>
+ <div class="float-right">
+ <a class="button button-small" href="/logout">logout</a>
+ </div>
+</div>
+
+<!-- IF TYPE == "user" -->
+
+
+<h4>Update user "{USERNAME}"</h4>
+
+<h5>Change password</h5>
+
+<!-- IF PASSWD_MSG -->
+<p> {PASSWD_MSG} </p>
+<!-- ENDIF -->
+
+<form method="post" action="/update">
+ <fieldset>
+ <label for="password1">New password:</label>
+ <input type="password" name="password1" id="password1" required>
+
+ <label for="password1">Repeat new password:</label>
+ <input type="password" name="password2" id="password2" required>
+
+ <input type="hidden" name="type" value="password">
+ <input type="hidden" name="username" value="{USERNAME}">
+ <input class="button-primary" type="submit" value="update password">
+ </fieldset>
+</form>
+
+<form method="post" action="/delete">
+ <fieldset>
+ <input type="hidden" name="type" value="user">
+ <input type="hidden" name="username" value="{USERNAME}">
+ <input class="button-primary" type="submit" value="delete">
+ </fieldset>
+</form>
+
+<!-- IF not ACTIVE-->
+
+<form method="post" action="/update">
+ <fieldset>
+ <input type="hidden" name="type" value="user">
+ <input type="hidden" name="username" value="{USERNAME}">
+ <input type="hidden" name="active" value="1">
+
+ <input class="button-primary" type="submit" value="activate">
+ </fieldset>
+</form>
+
+<!-- ELSE -->
+
+<form method="post" action="/update">
+ <fieldset>
+ <input type="hidden" name="type" value="user">
+ <input type="hidden" name="username" value="{USERNAME}">
+ <input type="hidden" name="active" value="0">
+
+ <input class="button-primary" type="submit" value="deactivate">
+ </fieldset>
+</form>
+
+<!-- ENDIF -->
+<!-- ELSEIF TYPE == "calendar" -->
+
+<h5>Calendars</h5>
+<table>
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>URI</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr>
+ <td>Default calendar</td>
+ <td>https://dav.ctu.cx/dav/calendars/c/default</td>
+ <td><a href="">rename</a> - <a href="">delete</a></td>
+ </tr>
+ <tr>
+ <td>foobar</td>
+ <td>https://dav.ctu.cx/dav/calendars/c/foobar</td>
+ <td><a href="">rename</a> - <a href="">delete</a></td>
+ </tr>
+ </tbody>
+</table>
+
+<h5>Addressbooks</h5>
+<table>
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>URI</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr>
+ <td>Default addressbook</td>
+ <td>https://dav.ctu.cx/dav/addressbooks/c/default</td>
+ <td><a href="">rename</a> - <a href="">delete</a></td>
+ </tr>
+ <tr>
+ <td>foobar</td>
+ <td>https://dav.ctu.cx/dav/addressbookss/c/foobar</td>
+ <td><a href="">rename</a> - <a href="">delete</a></td>
+ </tr>
+ </tbody>
+</table>
+
+<h5>New</h5>
+<form method="post" action="/create">
+ <fieldset>
+ <label for="type">Type</label>
+ <select id="type" name="type">
+ <option value="calendar">Calendar</option>
+ <option value="addressbook">Addressbook</option>
+ </select>
+
+
+ <label for="displayname">Displayname:</label>
+ <input type="text" name="displayname" id="displayname">
+
+ <input class="button-primary" type="submit" value="create">
+ </fieldset>
+</form>
+
+<!-- ENDIF -->
+<!-- INCLUDE footer.tpl --> \ No newline at end of file