OwlCyberSecurity - MANAGER
Edit File: purge.py
#!/usr/bin/env python # -*- test-case-name: calendarserver.tools.test.test_purge -*- ## # Copyright (c) 2006-2017 Apple Inc. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ## from __future__ import print_function import collections import datetime from getopt import getopt, GetoptError import os import sys from calendarserver.tools import tables from calendarserver.tools.cmdline import utilityMain, WorkerService from pycalendar.datetime import DateTime from twext.enterprise.dal.record import fromTable from twext.enterprise.dal.syntax import Delete, Select, Union, Parameter, Max from twext.enterprise.jobs.workitem import WorkItem, RegeneratingWorkItem from twext.python.log import Logger from twisted.internet.defer import inlineCallbacks, returnValue, succeed from twistedcaldav import caldavxml from twistedcaldav.config import config from twistedcaldav.dateops import parseSQLDateToPyCalendar, pyCalendarToSQLTimestamp from twistedcaldav.ical import Component, InvalidICalendarDataError from txdav.caldav.datastore.query.filter import Filter from txdav.common.datastore.sql_tables import schema, _HOME_STATUS_NORMAL, _BIND_MODE_OWN log = Logger() DEFAULT_BATCH_SIZE = 100 DEFAULT_RETAIN_DAYS = 365 class PrincipalPurgePollingWork( RegeneratingWorkItem, fromTable(schema.PRINCIPAL_PURGE_POLLING_WORK) ): """ A work item that scans the existing set of provisioned homes in the store and creates a work item for each to be checked against the directory to see if they need purging. """ group = "principal_purge_polling" @classmethod def initialSchedule(cls, store, seconds): def _enqueue(txn): return PrincipalPurgePollingWork.reschedule(txn, seconds) if config.AutomaticPurging.Enabled: return store.inTransaction("PrincipalPurgePollingWork.initialSchedule", _enqueue) else: return succeed(None) def regenerateInterval(self): """ Return the interval in seconds between regenerating instances. """ return config.AutomaticPurging.PollingIntervalSeconds if config.AutomaticPurging.Enabled else None @inlineCallbacks def doWork(self): # If not enabled, punt here if not config.AutomaticPurging.Enabled: returnValue(None) # Do the scan allUIDs = set() for home in (schema.CALENDAR_HOME, schema.ADDRESSBOOK_HOME): for [uid] in ( yield Select( [home.OWNER_UID], From=home, Where=(home.STATUS == _HOME_STATUS_NORMAL), ).on(self.transaction) ): allUIDs.add(uid) # Spread out the per-uid checks 0 second apart seconds = 0 for uid in allUIDs: notBefore = ( datetime.datetime.utcnow() + datetime.timedelta(seconds=config.AutomaticPurging.CheckStaggerSeconds) ) seconds += 1 yield self.transaction.enqueue( PrincipalPurgeCheckWork, uid=uid, notBefore=notBefore ) class PrincipalPurgeCheckWork( WorkItem, fromTable(schema.PRINCIPAL_PURGE_CHECK_WORK) ): """ Work item for checking for the existence of a UID in the directory. This work item is created by L{PrincipalPurgePollingWork} - one for each unique user UID to check. """ group = property(lambda self: (self.table.UID == self.uid)) @inlineCallbacks def doWork(self): # Delete any other work items for this UID yield Delete( From=self.table, Where=self.group, ).on(self.transaction) # If not enabled, punt here if not config.AutomaticPurging.Enabled: returnValue(None) log.debug("Checking for existence of {uid} in directory", uid=self.uid) directory = self.transaction.store().directoryService() record = yield directory.recordWithUID(self.uid) if record is None: # Schedule purge of this UID a week from now notBefore = ( datetime.datetime.utcnow() + datetime.timedelta(seconds=config.AutomaticPurging.PurgeIntervalSeconds) ) log.warn( "Principal {uid} is no longer in the directory; scheduling clean-up at {when}", uid=self.uid, when=notBefore ) yield self.transaction.enqueue( PrincipalPurgeWork, uid=self.uid, notBefore=notBefore ) else: log.debug("{uid} is still in the directory", uid=self.uid) class PrincipalPurgeWork( WorkItem, fromTable(schema.PRINCIPAL_PURGE_WORK) ): """ Work item for purging a UID's data """ group = property(lambda self: (self.table.UID == self.uid)) @inlineCallbacks def doWork(self): # Delete any other work items for this UID yield Delete( From=self.table, Where=self.group, ).on(self.transaction) # If not enabled, punt here if not config.AutomaticPurging.Enabled: returnValue(None) # Check for UID in directory again log.debug("One last existence check for {uid}", uid=self.uid) directory = self.transaction.store().directoryService() record = yield directory.recordWithUID(self.uid) if record is None: # Time to go service = PurgePrincipalService(self.transaction.store()) log.warn( "Cleaning up future events for principal {uid} since they are no longer in directory", uid=self.uid ) yield service.purgeUIDs( self.transaction.store(), directory, [self.uid], proxies=True, when=None ) else: log.debug("{uid} has re-appeared in the directory", uid=self.uid) class PrincipalPurgeHomeWork( WorkItem, fromTable(schema.PRINCIPAL_PURGE_HOME_WORK) ): """ Work item for removing a UID's home """ group = property(lambda self: (self.table.HOME_RESOURCE_ID == self.homeResourceID)) @inlineCallbacks def doWork(self): # Delete any other work items for this UID yield Delete( From=self.table, Where=self.group, ).on(self.transaction) # NB We do not check config.AutomaticPurging.Enabled here because if this work # item was enqueued we always need to complete it # Check for pending scheduling operations sow = schema.SCHEDULE_ORGANIZER_WORK sosw = schema.SCHEDULE_ORGANIZER_SEND_WORK srw = schema.SCHEDULE_REPLY_WORK rows = yield Select( [sow.HOME_RESOURCE_ID], From=sow, Where=(sow.HOME_RESOURCE_ID == self.homeResourceID), SetExpression=Union( Select( [sosw.HOME_RESOURCE_ID], From=sosw, Where=(sosw.HOME_RESOURCE_ID == self.homeResourceID), SetExpression=Union( Select( [srw.HOME_RESOURCE_ID], From=srw, Where=(srw.HOME_RESOURCE_ID == self.homeResourceID), ) ), ) ), ).on(self.transaction) if rows and len(rows): # Regenerate this job notBefore = ( datetime.datetime.utcnow() + datetime.timedelta(seconds=config.AutomaticPurging.HomePurgeDelaySeconds) ) yield self.transaction.enqueue( PrincipalPurgeHomeWork, homeResourceID=self.homeResourceID, notBefore=notBefore ) else: # Get the home and remove it - only if properly marked as being purged home = yield self.transaction.calendarHomeWithResourceID(self.homeResourceID) if home.purging(): yield home.remove() class PurgeOldEventsService(WorkerService): uuid = None cutoff = None batchSize = None dryrun = False debug = False @classmethod def usage(cls, e=None): name = os.path.basename(sys.argv[0]) print("usage: %s [options]" % (name,)) print("") print(" Remove old events from the calendar server") print("") print("options:") print(" -h --help: print this help and exit") print(" -f --config <path>: Specify caldavd.plist configuration path") print(" -u --uuid <uuid>: Only process this user(s) [REQUIRED]") print(" -d --days <number>: specify how many days in the past to retain (default=%d)" % (DEFAULT_RETAIN_DAYS,)) print(" -n --dry-run: calculate how many events to purge, but do not purge data") print(" -v --verbose: print progress information") print(" -D --debug: debug logging") print("") if e: sys.stderr.write("%s\n" % (e,)) sys.exit(64) else: sys.exit(0) @classmethod def main(cls): try: (optargs, args) = getopt( sys.argv[1:], "Dd:b:f:hnu:v", [ "days=", "batch=", "dry-run", "config=", "uuid=", "help", "verbose", "debug", ], ) except GetoptError, e: cls.usage(e) # # Get configuration # configFileName = None uuid = None days = DEFAULT_RETAIN_DAYS batchSize = DEFAULT_BATCH_SIZE dryrun = False verbose = False debug = False for opt, arg in optargs: if opt in ("-h", "--help"): cls.usage() elif opt in ("-d", "--days"): try: days = int(arg) except ValueError, e: print("Invalid value for --days: %s" % (arg,)) cls.usage(e) elif opt in ("-b", "--batch"): try: batchSize = int(arg) except ValueError, e: print("Invalid value for --batch: %s" % (arg,)) cls.usage(e) elif opt in ("-v", "--verbose"): verbose = True elif opt in ("-D", "--debug"): debug = True elif opt in ("-n", "--dry-run"): dryrun = True elif opt in ("-f", "--config"): configFileName = arg elif opt in ("-u", "--uuid"): uuid = arg else: raise NotImplementedError(opt) if args: cls.usage("Too many arguments: %s" % (args,)) if uuid is None: cls.usage("uuid must be specified") cls.uuid = uuid if dryrun: verbose = True cutoff = DateTime.getToday() cutoff.setDateOnly(False) cutoff.offsetDay(-days) cls.cutoff = cutoff cls.batchSize = batchSize cls.dryrun = dryrun cls.debug = debug utilityMain( configFileName, cls, verbose=verbose, ) @classmethod @inlineCallbacks def purgeOldEvents(cls, store, uuid, cutoff, batchSize, debug=False, dryrun=False): service = cls(store) service.uuid = uuid service.cutoff = cutoff service.batchSize = batchSize service.dryrun = dryrun service.debug = debug result = yield service.doWork() returnValue(result) @inlineCallbacks def getMatchingHomeUIDs(self): """ Find all the calendar homes that match the uuid cli argument. """ log.debug("Searching for calendar homes matching: '{uid}'", uid=self.uuid) txn = self.store.newTransaction(label="Find matching homes") ch = schema.CALENDAR_HOME if self.uuid: kwds = {"uuid": self.uuid} rows = (yield Select( [ch.RESOURCE_ID, ch.OWNER_UID, ], From=ch, Where=(ch.OWNER_UID.StartsWith(Parameter("uuid"))), ).on(txn, **kwds)) else: rows = (yield Select( [ch.RESOURCE_ID, ch.OWNER_UID, ], From=ch, ).on(txn)) yield txn.commit() log.debug(" Found {len} calendar homes", len=len(rows)) returnValue(sorted(rows, key=lambda x: x[1])) @inlineCallbacks def getMatchingCalendarIDs(self, home_id, owner_uid): """ Find all the owned calendars for the specified calendar home. @param home_id: resource-id of calendar home to check @type home_id: L{int} @param owner_uid: owner UUID of home to check @type owner_uid: L{str} """ log.debug("Checking calendar home: {id} '{uid}'", id=home_id, uid=owner_uid) txn = self.store.newTransaction(label="Find matching calendars") cb = schema.CALENDAR_BIND kwds = {"home_id": home_id} rows = (yield Select( [cb.CALENDAR_RESOURCE_ID, cb.CALENDAR_RESOURCE_NAME, ], From=cb, Where=(cb.CALENDAR_HOME_RESOURCE_ID == Parameter("home_id")).And( cb.BIND_MODE == _BIND_MODE_OWN ), ).on(txn, **kwds)) yield txn.commit() log.debug(" Found {len} calendars", len=len(rows)) returnValue(rows) PurgeEvent = collections.namedtuple("PurgeEvent", ("home", "calendar", "resource",)) @inlineCallbacks def getResourceIDsToPurge(self, home_id, calendar_id, calendar_name): """ For the given calendar find which calendar objects are older than the cut-off and return the resource-ids of those. @param home_id: resource-id of calendar home @type home_id: L{int} @param calendar_id: resource-id of the calendar to check @type calendar_id: L{int} @param calendar_name: name of the calendar to check @type calendar_name: L{str} """ log.debug(" Checking calendar: {id} '{name}'", id=calendar_id, name=calendar_name) purge = set() txn = self.store.newTransaction(label="Find matching resources") co = schema.CALENDAR_OBJECT tr = schema.TIME_RANGE kwds = {"calendar_id": calendar_id} rows = (yield Select( [co.RESOURCE_ID, co.RECURRANCE_MAX, co.RECURRANCE_MIN, Max(tr.END_DATE)], From=co.join(tr, on=(co.RESOURCE_ID == tr.CALENDAR_OBJECT_RESOURCE_ID)), Where=(co.CALENDAR_RESOURCE_ID == Parameter("calendar_id")).And( co.ICALENDAR_TYPE == "VEVENT" ), GroupBy=(co.RESOURCE_ID, co.RECURRANCE_MAX, co.RECURRANCE_MIN,), Having=( (co.RECURRANCE_MAX == None).And(Max(tr.END_DATE) < pyCalendarToSQLTimestamp(self.cutoff)) ).Or( (co.RECURRANCE_MAX != None).And(co.RECURRANCE_MAX < pyCalendarToSQLTimestamp(self.cutoff)) ), ).on(txn, **kwds)) log.debug(" Found {len} resources to check", len=len(rows)) for resource_id, recurrence_max, recurrence_min, max_end_date in rows: recurrence_max = parseSQLDateToPyCalendar(recurrence_max) if recurrence_max else None recurrence_min = parseSQLDateToPyCalendar(recurrence_min) if recurrence_min else None max_end_date = parseSQLDateToPyCalendar(max_end_date) if max_end_date else None # Find events where we know the max(end_date) represents a valid, # untruncated expansion if recurrence_min is None or recurrence_min < self.cutoff: if recurrence_max is None: # Here we know max_end_date is the fully expand final instance if max_end_date < self.cutoff: purge.add(self.PurgeEvent(home_id, calendar_id, resource_id,)) continue elif recurrence_max > self.cutoff: # Here we know that there are instances newer than the cut-off # but they have not yet been indexed out that far continue # Manually detect the max_end_date from the actual calendar data calendar = yield self.getCalendar(txn, resource_id) if calendar is not None: if self.checkLastInstance(calendar): purge.add(self.PurgeEvent(home_id, calendar_id, resource_id,)) yield txn.commit() log.debug(" Found {len} resources to purge", len=len(purge)) returnValue(purge) @inlineCallbacks def getCalendar(self, txn, resid): """ Get the calendar data for a calendar object resource. @param resid: resource-id of the calendar object resource to load @type resid: L{int} """ co = schema.CALENDAR_OBJECT kwds = {"ResourceID": resid} rows = (yield Select( [co.ICALENDAR_TEXT], From=co, Where=( co.RESOURCE_ID == Parameter("ResourceID") ), ).on(txn, **kwds)) try: caldata = Component.fromString(rows[0][0]) if rows else None except InvalidICalendarDataError: returnValue(None) returnValue(caldata) def checkLastInstance(self, calendar): """ Determine the last instance of a calendar event. Try a "static" analysis of the data first, and only if needed, do an instance expansion. @param calendar: the calendar object to examine @type calendar: L{Component} """ # Is it recurring master = calendar.masterComponent() if not calendar.isRecurring() or master is None: # Just check the end date for comp in calendar.subcomponents(): if comp.name() == "VEVENT": if comp.getEndDateUTC() > self.cutoff: return False else: return True elif calendar.isRecurringUnbounded(): return False else: # First test all sub-components # Just check the end date for comp in calendar.subcomponents(): if comp.name() == "VEVENT": if comp.getEndDateUTC() > self.cutoff: return False # If we get here we need to test the RRULE - if there is an until use # that as the end point, if a count, we have to expand rrules = tuple(master.properties("RRULE")) if len(rrules): if rrules[0].value().getUseUntil(): return rrules[0].value().getUntil() < self.cutoff else: return not calendar.hasInstancesAfter(self.cutoff) return True @inlineCallbacks def getResourcesToPurge(self, home_id, owner_uid): """ Find all the resource-ids of calendar object resources that need to be purged in the specified home. @param home_id: resource-id of calendar home to check @type home_id: L{int} @param owner_uid: owner UUID of home to check @type owner_uid: L{str} """ purge = set() calendars = yield self.getMatchingCalendarIDs(home_id, owner_uid) for calendar_id, calendar_name in calendars: purge.update((yield self.getResourceIDsToPurge(home_id, calendar_id, calendar_name))) returnValue(purge) @inlineCallbacks def purgeResources(self, events): """ Remove up to batchSize events and return how many were removed. """ txn = self.store.newTransaction(label="Remove old events") count = 0 last_home = None last_calendar = None for event in events: if event.home != last_home: home = (yield txn.calendarHomeWithResourceID(event.home)) last_home = event.home if event.calendar != last_calendar: calendar = (yield home.childWithID(event.calendar)) last_calendar = event.calendar resource = (yield calendar.objectResourceWithID(event.resource)) yield resource.purge(implicitly=False) log.debug( "Removed resource {id} '{name}' from calendar {pid} '{pname}' of calendar home '{uid}'", id=resource.id(), name=resource.name(), pid=resource.parentCollection().id(), pname=resource.parentCollection().name(), uid=resource.parentCollection().ownerHome().uid() ) count += 1 yield txn.commit() returnValue(count) @inlineCallbacks def doWork(self): if self.debug: # Turn on debug logging for this module config.LogLevels[__name__] = "debug" else: config.LogLevels[__name__] = "info" config.update() homes = yield self.getMatchingHomeUIDs() if not homes: log.info("No homes to process") returnValue(0) if self.dryrun: log.info("Purge dry run only") log.info("Searching for old events...") purge = set() homes = yield self.getMatchingHomeUIDs() for home_id, owner_uid in homes: purge.update((yield self.getResourcesToPurge(home_id, owner_uid))) if self.dryrun: eventCount = len(purge) if eventCount == 0: log.info("No events are older than {cutoff}", cutoff=self.cutoff) elif eventCount == 1: log.info("1 event is older than {cutoff}", cutoff=self.cutoff) else: log.info("{count} events are older than {cutoff}", count=eventCount, cutoff=self.cutoff) returnValue(eventCount) purge = list(purge) purge.sort() totalEvents = len(purge) log.info("Removing {len} events older than {cutoff}...", len=len(purge), cutoff=self.cutoff) numEventsRemoved = -1 totalRemoved = 0 while numEventsRemoved: numEventsRemoved = (yield self.purgeResources(purge[:self.batchSize])) if numEventsRemoved: totalRemoved += numEventsRemoved log.debug(" Removed {removed} of {total} events...", removed=totalRemoved, total=totalEvents) purge = purge[numEventsRemoved:] if totalRemoved == 0: log.info("No events were removed") elif totalRemoved == 1: log.info("1 event was removed in total") else: log.info("{total} events were removed in total", total=totalRemoved) returnValue(totalRemoved) class PurgeAttachmentsService(WorkerService): uuid = None cutoff = None batchSize = None dryrun = False verbose = False @classmethod def usage(cls, e=None): name = os.path.basename(sys.argv[0]) print("usage: %s [options]" % (name,)) print("") print(" Remove old or orphaned attachments from the calendar server") print("") print("options:") print(" -h --help: print this help and exit") print(" -f --config <path>: Specify caldavd.plist configuration path") print(" -u --uuid <owner uid>: target a specific user UID") # print(" -b --batch <number>: number of attachments to remove in each transaction (default=%d)" % (DEFAULT_BATCH_SIZE,)) print(" -d --days <number>: specify how many days in the past to retain (default=%d) zero means no removal of old attachments" % (DEFAULT_RETAIN_DAYS,)) print(" -n --dry-run: calculate how many attachments to purge, but do not purge data") print(" -v --verbose: print progress information") print(" -D --debug: debug logging") print("") if e: sys.stderr.write("%s\n" % (e,)) sys.exit(64) else: sys.exit(0) @classmethod def main(cls): try: (optargs, args) = getopt( sys.argv[1:], "Dd:b:f:hnu:v", [ "uuid=", "days=", "batch=", "dry-run", "config=", "help", "verbose", "debug", ], ) except GetoptError, e: cls.usage(e) # # Get configuration # configFileName = None uuid = None days = DEFAULT_RETAIN_DAYS batchSize = DEFAULT_BATCH_SIZE dryrun = False verbose = False debug = False for opt, arg in optargs: if opt in ("-h", "--help"): cls.usage() elif opt in ("-u", "--uuid"): uuid = arg elif opt in ("-d", "--days"): try: days = int(arg) except ValueError, e: print("Invalid value for --days: %s" % (arg,)) cls.usage(e) elif opt in ("-b", "--batch"): try: batchSize = int(arg) except ValueError, e: print("Invalid value for --batch: %s" % (arg,)) cls.usage(e) elif opt in ("-v", "--verbose"): verbose = True elif opt in ("-D", "--debug"): debug = True elif opt in ("-n", "--dry-run"): dryrun = True elif opt in ("-f", "--config"): configFileName = arg else: raise NotImplementedError(opt) if args: cls.usage("Too many arguments: %s" % (args,)) if dryrun: verbose = True cls.uuid = uuid if days > 0: cutoff = DateTime.getToday() cutoff.setDateOnly(False) cutoff.offsetDay(-days) cls.cutoff = cutoff else: cls.cutoff = None cls.batchSize = batchSize cls.dryrun = dryrun cls.verbose = verbose utilityMain( configFileName, cls, verbose=debug, ) @classmethod @inlineCallbacks def purgeAttachments(cls, store, uuid, days, limit, dryrun, verbose): service = cls(store) service.uuid = uuid if days > 0: cutoff = DateTime.getToday() cutoff.setDateOnly(False) cutoff.offsetDay(-days) service.cutoff = cutoff else: service.cutoff = None service.batchSize = limit service.dryrun = dryrun service.verbose = verbose result = yield service.doWork() returnValue(result) @inlineCallbacks def doWork(self): if self.dryrun: orphans = yield self._orphansDryRun() if self.cutoff is not None: dropbox = yield self._dropboxDryRun() managed = yield self._managedDryRun() else: dropbox = () managed = () returnValue(self._dryRunSummary(orphans, dropbox, managed)) else: total = yield self._orphansPurge() if self.cutoff is not None: total += yield self._dropboxPurge() total += yield self._managedPurge() returnValue(total) @inlineCallbacks def _orphansDryRun(self): if self.verbose: print("(Dry run) Searching for orphaned attachments...") txn = self.store.newTransaction(label="Find orphaned attachments") orphans = yield txn.orphanedAttachments(self.uuid) returnValue(orphans) @inlineCallbacks def _dropboxDryRun(self): if self.verbose: print("(Dry run) Searching for old dropbox attachments...") txn = self.store.newTransaction(label="Find old dropbox attachments") cutoffs = yield txn.oldDropboxAttachments(self.cutoff, self.uuid) yield txn.commit() returnValue(cutoffs) @inlineCallbacks def _managedDryRun(self): if self.verbose: print("(Dry run) Searching for old managed attachments...") txn = self.store.newTransaction(label="Find old managed attachments") cutoffs = yield txn.oldManagedAttachments(self.cutoff, self.uuid) yield txn.commit() returnValue(cutoffs) def _dryRunSummary(self, orphans, dropbox, managed): if self.verbose: byuser = {} ByUserData = collections.namedtuple( 'ByUserData', ['quota', 'orphanSize', 'orphanCount', 'dropboxSize', 'dropboxCount', 'managedSize', 'managedCount'] ) for user, quota, size, count in orphans: byuser[user] = ByUserData(quota=quota, orphanSize=size, orphanCount=count, dropboxSize=0, dropboxCount=0, managedSize=0, managedCount=0) for user, quota, size, count in dropbox: if user in byuser: byuser[user] = byuser[user]._replace(dropboxSize=size, dropboxCount=count) else: byuser[user] = ByUserData(quota=quota, orphanSize=0, orphanCount=0, dropboxSize=size, dropboxCount=count, managedSize=0, managedCount=0) for user, quota, size, count in managed: if user in byuser: byuser[user] = byuser[user]._replace(managedSize=size, managedCount=count) else: byuser[user] = ByUserData(quota=quota, orphanSize=0, orphanCount=0, dropboxSize=0, dropboxCount=0, managedSize=size, managedCount=count) # Print table of results table = tables.Table() table.addHeader(("User", "Current Quota", "Orphan Size", "Orphan Count", "Dropbox Size", "Dropbox Count", "Managed Size", "Managed Count", "Total Size", "Total Count")) table.setDefaultColumnFormats(( tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.LEFT_JUSTIFY), tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY), tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY), tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY), tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY), tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY), tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY), tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY), tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY), tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY), )) totals = [0] * 8 for user, data in sorted(byuser.items(), key=lambda x: x[0]): cols = ( data.orphanSize, data.orphanCount, data.dropboxSize, data.dropboxCount, data.managedSize, data.managedCount, data.orphanSize + data.dropboxSize + data.managedSize, data.orphanCount + data.dropboxCount + data.managedCount, ) table.addRow((user, data.quota,) + cols) for ctr, value in enumerate(cols): totals[ctr] += value table.addFooter(("Total:", "",) + tuple(totals)) total = totals[7] print("\n") print("Orphaned/Old Attachments by User:\n") table.printTable() else: total = sum([x[3] for x in orphans]) + sum([x[3] for x in dropbox]) + sum([x[3] for x in managed]) return total @inlineCallbacks def _orphansPurge(self): if self.verbose: print("Removing orphaned attachments...",) numOrphansRemoved = -1 totalRemoved = 0 while numOrphansRemoved: txn = self.store.newTransaction(label="Remove orphaned attachments") numOrphansRemoved = yield txn.removeOrphanedAttachments(self.uuid, batchSize=self.batchSize) yield txn.commit() if numOrphansRemoved: totalRemoved += numOrphansRemoved if self.verbose: print(" %d," % (totalRemoved,),) elif self.verbose: print("") if self.verbose: if totalRemoved == 0: print("No orphaned attachments were removed") elif totalRemoved == 1: print("1 orphaned attachment was removed in total") else: print("%d orphaned attachments were removed in total" % (totalRemoved,)) print("") returnValue(totalRemoved) @inlineCallbacks def _dropboxPurge(self): if self.verbose: print("Removing old dropbox attachments...",) numOldRemoved = -1 totalRemoved = 0 while numOldRemoved: txn = self.store.newTransaction(label="Remove old dropbox attachments") numOldRemoved = yield txn.removeOldDropboxAttachments(self.cutoff, self.uuid, batchSize=self.batchSize) yield txn.commit() if numOldRemoved: totalRemoved += numOldRemoved if self.verbose: print(" %d," % (totalRemoved,),) elif self.verbose: print("") if self.verbose: if totalRemoved == 0: print("No old dropbox attachments were removed") elif totalRemoved == 1: print("1 old dropbox attachment was removed in total") else: print("%d old dropbox attachments were removed in total" % (totalRemoved,)) print("") returnValue(totalRemoved) @inlineCallbacks def _managedPurge(self): if self.verbose: print("Removing old managed attachments...",) numOldRemoved = -1 totalRemoved = 0 while numOldRemoved: txn = self.store.newTransaction(label="Remove old managed attachments") numOldRemoved = yield txn.removeOldManagedAttachments(self.cutoff, self.uuid, batchSize=self.batchSize) yield txn.commit() if numOldRemoved: totalRemoved += numOldRemoved if self.verbose: print(" %d," % (totalRemoved,),) elif self.verbose: print("") if self.verbose: if totalRemoved == 0: print("No old managed attachments were removed") elif totalRemoved == 1: print("1 old managed attachment was removed in total") else: print("%d old managed attachments were removed in total" % (totalRemoved,)) print("") returnValue(totalRemoved) class PurgePrincipalService(WorkerService): root = None directory = None uids = None dryrun = False verbose = False proxies = True when = None @classmethod def usage(cls, e=None): name = os.path.basename(sys.argv[0]) print("usage: %s [options]" % (name,)) print("") print(" Remove a principal's events and contacts from the calendar server") print(" Future events are declined or cancelled") print("") print("options:") print(" -h --help: print this help and exit") print(" -f --config <path>: Specify caldavd.plist configuration path") print(" -n --dry-run: calculate how many events and contacts to purge, but do not purge data") print(" -v --verbose: print progress information") print(" -D --debug: debug logging") print("") if e: sys.stderr.write("%s\n" % (e,)) sys.exit(64) else: sys.exit(0) @classmethod def main(cls): try: (optargs, args) = getopt( sys.argv[1:], "Df:hnv", [ "dry-run", "config=", "help", "verbose", "debug", ], ) except GetoptError, e: cls.usage(e) # # Get configuration # configFileName = None dryrun = False verbose = False debug = False for opt, arg in optargs: if opt in ("-h", "--help"): cls.usage() elif opt in ("-v", "--verbose"): verbose = True elif opt in ("-D", "--debug"): debug = True elif opt in ("-n", "--dry-run"): dryrun = True elif opt in ("-f", "--config"): configFileName = arg else: raise NotImplementedError(opt) # args is a list of uids cls.uids = args cls.dryrun = dryrun cls.verbose = verbose utilityMain( configFileName, cls, verbose=debug, ) @classmethod @inlineCallbacks def purgeUIDs(cls, store, directory, uids, verbose=False, dryrun=False, proxies=True, when=None): service = cls(store) service.directory = directory service.uids = uids service.verbose = verbose service.dryrun = dryrun service.proxies = proxies service.when = when result = yield service.doWork() returnValue(result) @inlineCallbacks def doWork(self): if self.directory is None: self.directory = self.store.directoryService() total = 0 for uid in self.uids: count = yield self._purgeUID(uid) total += count if self.verbose: amount = "%d event%s" % (total, "s" if total > 1 else "") if self.dryrun: print("Would have modified or deleted %s" % (amount,)) else: print("Modified or deleted %s" % (amount,)) returnValue(total) @inlineCallbacks def _purgeUID(self, uid): if self.when is None: self.when = DateTime.getNowUTC() cuas = set(( "urn:uuid:{}".format(uid), "urn:x-uid:{}".format(uid) )) # See if calendar home is provisioned txn = self.store.newTransaction() storeCalHome = yield txn.calendarHomeWithUID(uid) calHomeProvisioned = storeCalHome is not None # Always, unshare collections, remove notifications if calHomeProvisioned: yield self._cleanHome(txn, storeCalHome) yield txn.commit() count = 0 if calHomeProvisioned: count = yield self._cancelEvents(txn, uid, cuas) # Remove empty calendar collections (and calendar home if no more # calendars) yield self._removeCalendarHome(uid) # Remove VCards count += (yield self._removeAddressbookHome(uid)) if self.proxies and not self.dryrun: if self.verbose: print("Deleting any proxy assignments") yield self._purgeProxyAssignments(self.store, uid) returnValue(count) @inlineCallbacks def _cleanHome(self, txn, storeCalHome): # Process shared and shared-to-me calendars children = list((yield storeCalHome.children())) for child in children: if self.verbose: if self.dryrun: print("Would unshare: %s" % (child.name(),)) else: print("Unsharing: %s" % (child.name(),)) if not self.dryrun: yield child.unshare() if not self.dryrun: yield storeCalHome.removeUnacceptedShares() notificationHome = yield txn.notificationsWithUID(storeCalHome.uid()) if notificationHome is not None: yield notificationHome.purge() @inlineCallbacks def _cancelEvents(self, txn, uid, cuas): # Anything in the past is left alone whenString = self.when.getText() query_filter = caldavxml.Filter( caldavxml.ComponentFilter( caldavxml.ComponentFilter( caldavxml.TimeRange(start=whenString,), name=("VEVENT",), ), name="VCALENDAR", ) ) query_filter = Filter(query_filter) count = 0 txn = self.store.newTransaction() storeCalHome = yield txn.calendarHomeWithUID(uid) calendarNames = yield storeCalHome.listCalendars() yield txn.commit() for calendarName in calendarNames: txn = self.store.newTransaction(authz_uid=uid) storeCalHome = yield txn.calendarHomeWithUID(uid) calendar = yield storeCalHome.calendarWithName(calendarName) allChildNames = [] futureChildNames = set() # Only purge owned calendars if calendar.owned(): # all events for childName in (yield calendar.listCalendarObjects()): allChildNames.append(childName) # events matching filter for childName, _ignore_childUid, _ignore_childType in (yield calendar.search(query_filter)): futureChildNames.add(childName) yield txn.commit() for childName in allChildNames: txn = self.store.newTransaction(authz_uid=uid) storeCalHome = yield txn.calendarHomeWithUID(uid) calendar = yield storeCalHome.calendarWithName(calendarName) doScheduling = childName in futureChildNames try: childResource = yield calendar.calendarObjectWithName(childName) uri = "/calendars/__uids__/%s/%s/%s" % (storeCalHome.uid(), calendar.name(), childName) incrementCount = self.dryrun if self.verbose: if self.dryrun: print("Would delete%s: %s" % (" with scheduling" if doScheduling else "", uri,)) else: print("Deleting%s: %s" % (" with scheduling" if doScheduling else "", uri,)) if not self.dryrun: retry = False try: yield childResource.purge(implicitly=doScheduling) incrementCount = True except Exception, e: print("Exception deleting %s: %s" % (uri, str(e))) retry = True if retry and doScheduling: # Try again with implicit scheduling off print("Retrying deletion of %s with scheduling turned off" % (uri,)) try: yield childResource.purge(implicitly=False) incrementCount = True except Exception, e: print("Still couldn't delete %s even with scheduling turned off: %s" % (uri, str(e))) if incrementCount: count += 1 # Commit yield txn.commit() except Exception, e: # Abort yield txn.abort() raise e returnValue(count) @inlineCallbacks def _removeCalendarHome(self, uid): try: txn = self.store.newTransaction(authz_uid=uid) # Remove empty calendar collections (and calendar home if no more # calendars) storeCalHome = yield txn.calendarHomeWithUID(uid) if storeCalHome is not None: calendars = list((yield storeCalHome.calendars())) calendars.extend(list((yield storeCalHome.calendars(onlyInTrash=True)))) remainingCalendars = len(calendars) for calColl in calendars: if len(list((yield calColl.calendarObjects()))) == 0: remainingCalendars -= 1 calendarName = calColl.name() if self.verbose: if self.dryrun: print("Would delete calendar: %s" % (calendarName,)) else: print("Deleting calendar: %s" % (calendarName,)) if not self.dryrun: if calColl.owned(): yield storeCalHome.removeChildWithName(calendarName, useTrash=False) else: yield calColl.unshare() if not remainingCalendars: if self.verbose: if self.dryrun: print("Would delete calendar home") else: print("Deleting calendar home") if not self.dryrun: # Queue a job to delete the calendar home after any scheduling operations # are complete notBefore = ( datetime.datetime.utcnow() + datetime.timedelta(seconds=config.AutomaticPurging.HomePurgeDelaySeconds) ) yield txn.enqueue( PrincipalPurgeHomeWork, homeResourceID=storeCalHome.id(), notBefore=notBefore ) # Also mark the home as purging so it won't be looked at again during # purge polling yield storeCalHome.purge() # Commit yield txn.commit() except Exception, e: # Abort yield txn.abort() raise e @inlineCallbacks def _removeAddressbookHome(self, uid): count = 0 txn = self.store.newTransaction(authz_uid=uid) try: # Remove VCards storeAbHome = yield txn.addressbookHomeWithUID(uid) if storeAbHome is not None: for abColl in list((yield storeAbHome.addressbooks())): for card in list((yield abColl.addressbookObjects())): cardName = card.name() if self.verbose: uri = "/addressbooks/__uids__/%s/%s/%s" % (uid, abColl.name(), cardName) if self.dryrun: print("Would delete: %s" % (uri,)) else: print("Deleting: %s" % (uri,)) if not self.dryrun: yield card.remove() count += 1 abName = abColl.name() if self.verbose: if self.dryrun: print("Would delete addressbook: %s" % (abName,)) else: print("Deleting addressbook: %s" % (abName,)) if not self.dryrun: # Also remove the addressbook collection itself if abColl.owned(): yield storeAbHome.removeChildWithName(abName, useTrash=False) else: yield abColl.unshare() if self.verbose: if self.dryrun: print("Would delete addressbook home") else: print("Deleting addressbook home") if not self.dryrun: yield storeAbHome.remove() # Commit yield txn.commit() except Exception, e: # Abort yield txn.abort() raise e returnValue(count) @inlineCallbacks def _purgeProxyAssignments(self, store, uid): txn = store.newTransaction() for readWrite in (True, False): yield txn.removeDelegates(uid, readWrite) yield txn.removeDelegateGroups(uid, readWrite) yield txn.commit()