34aacce4e812f05a4c3ec60b410bdcb9bb2fe83f
[jelmer/calypso.git] / calypso / webdav.py
1 # -*- coding: utf-8 -*-
2 #
3 # This file is part of Calypso - CalDAV/CardDAV/WebDAV Server
4 # Copyright © 2008-2011 Guillaume Ayoub
5 # Copyright © 2008 Nicolas Kandel
6 # Copyright © 2008 Pascal Halter
7 # Copyright © 2011 Keith Packard
8 #
9 # This library is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
13 #
14 # This library is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 # GNU General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with Calypso.  If not, see <http://www.gnu.org/licenses/>.
21
22 """
23 Calypso collection classes.
24
25 Define the main classes of a collection as seen from the server.
26
27 """
28
29 import os
30 import codecs
31 import time
32 import hashlib
33 import glob
34 import logging
35 import tempfile
36 import vobject
37 import re
38 import subprocess
39
40 import ConfigParser
41
42 from . import config, paths
43
44 METADATA_FILENAME = ".calypso-collection"
45
46 #
47 # Recursive search for 'name' within 'vobject'
48 #
49
50 def find_vobject_value(vobject, name):
51
52     if vobject.name == name:
53         return vobject.value
54
55     for child in vobject.getChildren():
56         value = find_vobject_value(child, name)
57         if value:
58             return value
59     return None
60
61 class Item(object):
62
63     """Internal item. Wraps a vObject"""
64
65     def __init__(self, text, name=None, path=None):
66         """Initialize object from ``text`` and different ``kwargs``."""
67
68         self.log = logging.getLogger(__name__)
69         try:
70             text = text.encode('utf8')
71         except UnicodeDecodeError:
72             text = text.decode('latin1').encode('utf-8')
73
74         # Strip out control characters
75
76         text = re.sub(r"[\x01-\x09\x0b-\x1F\x7F]","",text)
77
78         try:
79             self.object = vobject.readOne(text)
80         except Exception:
81             self.log.exception("Parse error in %s %s", name, path)
82             raise
83
84
85         if not self.object.contents.has_key('x-calypso-name'):
86             if not name:
87                 if self.object.name == 'VCARD' or self.object.name == 'VEVENT':
88                     if not self.object.contents.has_key('uid'):
89                         self.object.add('UID').value = hashlib.sha1(text).hexdigest()
90                     name = self.object.uid.value
91                 else:
92                     for child in self.object.getChildren():
93                         if child.name == 'VEVENT' or child.name == 'VCARD':
94                             if not child.contents.has_key('uid'):
95                                 child.add('UID').value = hashlib.sha1(text).hexdigest()
96                             name = child.uid.value
97                             break
98                     if not name:
99                         name = hashlib.sha1(text).hexdigest()
100                 
101             self.object.add("X-CALYPSO-NAME").value = name
102         else:
103             names = self.object.contents[u'x-calypso-name']
104             if len(names) > 1:
105                 self.object.contents[u'x-calypso-name'] = [names[0]]
106
107         self.path = path
108         self.name = self.object.x_calypso_name.value
109         self.tag = self.object.name
110         self.etag = hashlib.sha1(text).hexdigest()
111
112     @property
113     def is_vcard(self):
114         """Whether this item is a vcard entry"""
115         if self.object.name == 'VCARD':
116             return True
117         if self.object.name == 'VEVENT':
118             return False
119         if self.object.name == 'VCALENDAR':
120             return False
121         for child in self.object.getChildren():
122             if child.name == 'VCARD':
123                 return True
124             if child.name == 'VEVENT':
125                 return False
126         return False
127
128     @property
129     def is_vcal(self):
130         """Whether this item is a vcal entry"""
131         if self.object.name == 'VCARD':
132             return False
133         if self.object.name == 'VEVENT':
134             return True
135         if self.object.name == 'VCALENDAR':
136             return True
137         for child in self.object.getChildren():
138             if child.name == 'VCARD':
139                 return False
140             if child.name == 'VEVENT':
141                 return True
142         return False
143
144     @property
145     def file_prefix(self):
146         if self.is_vcard:
147             return 'card-'
148         if self.is_vcal:
149             return 'cal-'
150         return 'res-'
151
152     @property
153     def file_extension(self):
154         if self.is_vcard:
155             return '.vcf'
156         if self.is_vcal:
157             return '.ics'
158         return '.dav'
159
160     @property
161     def text(self):
162         """Item text.
163
164         Text is the serialized form of the item.
165
166         """
167         return self.object.serialize().decode('utf-8')
168
169     @property
170     def length(self):
171         return "%d" % len(self.text)
172
173     @property
174     def last_modified(self):
175         value = find_vobject_value(self.object, "LAST-MODIFIED")
176         if value:
177             return value.utctimetuple()
178         return time.gmtime()
179
180     def __unicode__(self):
181         fn = self.object.getChildValue("fn")
182         if fn:
183             return fn
184
185         if hasattr(self.object, "vevent"):
186             summary = self.object.vevent.getChildValue("summary")
187             dtstart = self.object.vevent.getChildValue("dtstart")
188             if summary and dtstart:
189                 return "%s (%s)"%(summary, dtstart)
190             if summary:
191                 return summary
192             if dtstart:
193                 return str(dtstart)
194
195             uid = self.object.vevent.getChildValue("uid")
196             if uid:
197                 return uid
198
199         uid = self.object.getChildValue("uid")
200         if uid:
201             return uid
202
203         return self.name
204
205
206 class Pathtime(object):
207     """Path name and timestamps"""
208
209     def __init__(self, path):
210         self.path = path
211         self.mtime = self.curmtime
212
213     @property
214     def curmtime(self):
215         return os.path.getmtime(self.path)
216
217     def is_up_to_date(self):
218         newmtime = self.curmtime
219         if newmtime == self.mtime:
220             return True
221         self.mtime = newmtime
222         return False
223
224 class CalypsoError(Exception):
225     def __init__(self, name, reason):
226         self.name = name
227         self.reason = reason
228
229     def __str__(self):
230         return "%s: %s" % (self.reason, self.file)
231
232 class Collection(object):
233     """Internal collection class."""
234
235     def get_description(self):
236         try:
237             return str(self.metadata.get('collection', 'description'))
238         except (ConfigParser.NoSectionError, ConfigParser.NoOptionError, ValueError):
239             pass
240
241         try:
242             f = codecs.open(os.path.join(self.path, ".git/description"), encoding='utf-8')
243         except IOError:
244             # .git/description is not present eg when the complete server is a single git repo
245             return self.urlpath
246         return f.read()
247
248     def read_file(self, path):
249         text = codecs.open(path,encoding='utf-8').read()
250         item = Item(text, None, path)
251         return item
252
253     def insert_file(self, path):
254         try:
255             item = self.read_file(path)
256             self.my_items.append(item)
257         except Exception, ex:
258             self.log.exception("Insert %s failed", path)
259             return
260
261     def remove_file(self, path):
262         old_items=[]
263         for old_item in self.my_items:
264             if old_item.path == path:
265                 old_items.append(old_item)
266         for old_item in old_items:
267             self.my_items.remove(old_item)
268         
269     def scan_file(self, path):
270         self.remove_file(path)
271         self.insert_file(path)
272
273     __metadatafile = property(lambda self: os.path.join(self.path, METADATA_FILENAME))
274
275     def scan_metadata(self, force):
276         try:
277             mtime = os.path.getmtime(self.__metadatafile)
278         except OSError:
279             mtime = 0
280             force = True
281
282         if not force and mtime == self.mtime and self.metadata is not None:
283             return
284
285         parser = ConfigParser.RawConfigParser()
286         parser.read(self.__metadatafile)
287         self.metadata = parser
288
289     def scan_dir(self, force):
290         try:
291             mtime = os.path.getmtime(self.path)
292         except OSError:
293             mtime = 0
294             force = True
295
296         self.scan_metadata(force)
297
298         if not force and mtime == self.mtime:
299             return
300         self.log.debug("Scan %s", self.path)
301         self.mtime = mtime
302         filenames = glob.glob(self.pattern)
303         newfiles = []
304         for filename in filenames:
305             if filename == METADATA_FILENAME:
306                 continue
307             for file in self.files:
308                 if filename == file.path:
309                     newfiles.append(file)
310                     if not file.is_up_to_date():
311                         self.log.debug("Changed %s", filename)
312                         self.scan_file(filename)
313                     break
314             else:
315                 if os.path.isdir(filename):
316                     self.log.debug("Ignoring directory %s in scan_dir", filename)
317                 else:
318                     self.log.debug("New %s", filename)
319                     newfiles.append(Pathtime(filename))
320                     self.insert_file(filename)
321         for file in self.files:
322             if not file.path in filenames:
323                 self.log.debug("Removed %s", file.path)
324                 self.remove_file(file.path)
325         h = hashlib.sha1()
326         for item in self.my_items:
327             h.update(item.etag)
328         self._ctag = '%d-' % self.mtime + h.hexdigest()
329         self.files = newfiles
330                 
331     def __init__(self, path):
332         """Initialize the collection with ``cal`` and ``user`` parameters."""
333         
334         self.log = logging.getLogger(__name__)
335         self.encoding = "utf-8"
336         self.urlpath = path
337         self.owner = paths.url_to_owner(path)
338         self.path = paths.url_to_file(path)
339         self.pattern = os.path.join(self.path, "*")
340         self.files = []
341         self.my_items = []
342         self.mtime = 0
343         self._ctag = ''
344         self.etag = hashlib.sha1(self.path).hexdigest()
345         self.metadata = None
346         self.metadata_mtime = None
347         self.scan_dir(False)
348         self.tag = "Collection"
349
350     def __str__(self):
351         return "Calendar-%s (at %s)" % (self.name, self.path)
352
353     def __repr__(self):
354         return "<Calendar %s>" % (self.name)
355         
356     def has_git(self):
357         return True
358
359     def git_commit(self, context):
360         args = ["git", "commit", "--allow-empty"]
361         env = {}
362
363         message = context.get('action', 'other action')
364
365         if "user" in context:
366             # use environment variables instead of --author to avoid git
367             # looking it up in previous commits if it doesn't seem well-formed
368             env['GIT_AUTHOR_NAME'] = context['user'] or "unknown"
369             env['GIT_AUTHOR_EMAIL'] = "%s@webdav"%context['user']
370             # supress a chatty message that we could configure author
371             # information explicitly in the config file. (slicing it in after
372             # the git command as position is important with git arguments)
373             args[1:1] = ["-c", "advice.implicitIdentity=false"]
374         if "user-agent" in context:
375             message += u"\n\nUser-Agent: %r"%context['user-agent']
376
377         args.extend(["-m", message.encode('utf8')])
378
379         subprocess.check_call(args, cwd=self.path, env=env)
380
381     def git_add(self, path, context):
382         if self.has_git():
383             subprocess.check_call(["git", "add", os.path.basename(path)], cwd=self.path)
384             self.git_commit(context=context)
385     
386     def git_rm(self, path, context):
387         if self.has_git():
388             subprocess.check_call(["git", "rm", os.path.basename(path)], cwd=self.path)
389             self.git_commit(context=context)
390
391     def git_change(self, path, context):
392         if self.has_git():
393             subprocess.check_call(["git", "add", os.path.basename(path)], cwd=self.path)
394             self.git_commit(context=context)
395             # Touch directory so that another running instance will update
396             try:
397                 os.utime(self.path, None)
398             except Exception, ex:
399                 self.log.exception("Failed to set directory mtime")
400             
401     def write_file(self, item):
402         fd, path = tempfile.mkstemp(item.file_extension, item.file_prefix, dir=self.path)
403         self.log.debug('Trying to write to %s', path)
404         file = os.fdopen(fd, 'w')
405         file.write(item.text.encode('utf-8'))
406         file.close()
407         self.log.debug('Wrote %s to %s', file, path)
408         return path
409
410     def create_file(self, item, context):
411         # Create directory if necessary
412         self.log.debug("Add %s", item.name)
413         if not os.path.exists(os.path.dirname(self.path)):
414             try:
415                 os.makedirs(os.path.dirname(self.path))
416             except OSError, ose:
417                 self.log.exception("Failed to make collection directory %s: %s", self.path, ose)
418                 raise
419
420         context['action'] = u'Add %s'%item
421
422         try:
423             path = self.write_file(item)
424             self.git_add(path, context=context)
425             self.scan_dir(True)
426         except OSError, ex:
427             self.log.exception("Error writing file")
428             raise
429         except Exception, ex:
430             self.log.exception("Caught Exception")
431             self.log.debug("Failed to create %s: %s", self.path,  ex)
432             raise
433
434     def destroy_file(self, item, context):
435         self.log.debug("Remove %s", item.name)
436
437         context['action'] = u'Remove %s'%item
438
439         try:
440             os.unlink(item.path)
441             self.git_rm(item.path, context=context)
442             self.scan_dir(True)
443         except Exception, ex:
444             self.log.exception("Failed to remove %s", item.path)
445             raise
446
447     def rewrite_file(self, item, context):
448         self.log.debug("Change %s", item.name)
449
450         context['action'] = u'Modify %s'%item
451
452         try:
453             new_path = self.write_file(item)
454             os.rename(new_path, item.path)
455             self.scan_file(item.path)
456             self.git_change(item.path, context=context)
457             self.scan_dir(True)
458         except Exception, ex:
459             self.log.exception("Failed to rewrite %s", item.path)
460             raise
461         
462     def get_item(self, name):
463         """Get collection item called ``name``."""
464         for item in self.my_items:
465             if item.name == name:
466                 return item
467         return None
468
469     def get_items(self, name):
470         """Get collection items called ``name``."""
471         items=[]
472         for item in self.my_items:
473             if item.name == name:
474                 items.append(item)
475         return items
476
477     def append(self, name, text, context):
478         """Append items from ``text`` to collection.
479
480         If ``name`` is given, give this name to new items in ``text``.
481
482         """
483
484         self.log.debug('append name %s', name)
485         try:
486             new_item = Item(text, name, None)
487         except Exception, e:
488             self.log.exception("Cannot create new item")
489             raise
490         if new_item.name in (item.name for item in self.my_items):
491             self.log.debug("Item %s already present %s" , new_item.name, self.get_item(new_item.name).path)
492             raise CalypsoError(new_item.name, "Item already present")
493         self.log.debug("New item %s", new_item.name)
494         self.create_file(new_item, context=context)
495         return new_item
496
497     def remove(self, name, context):
498         """Remove object named ``name`` from collection."""
499         self.log.debug("Remove object %s", name)
500         for old_item in self.my_items:
501             if old_item.name == name:
502                 self.destroy_file(old_item, context=context)
503                 
504     def replace(self, name, text, context):
505         """Replace content by ``text`` in objet named ``name`` in collection."""
506
507         path=None
508         old_item = self.get_item(name)
509         if old_item:
510             path = old_item.path
511
512         try:
513             new_item = Item(text, name, path)
514         except Exception:
515             self.log.exception("Failed to replace %s", name)
516             raise
517
518         ret = False
519         if path is not None:
520             self.log.debug('rewrite path %s', path)
521             self.rewrite_file(new_item, context=context)
522         else:
523             self.log.debug('remove and append item %s', name)
524             self.remove(name)
525             self.append(name, text, context=context)
526         return new_item
527
528     def import_item(self, new_item, path):
529         old_item = self.get_item(new_item.name)
530         if old_item:
531             new_item.path = old_item.path
532             self.rewrite_file(new_item, context={})
533             self.log.debug("Updated %s from %s", new_item.name, path)
534         else:
535             self.create_file(new_item, context={})
536             self.log.debug("Added %s from %s", new_item.name, path)
537
538     def import_file(self, path):
539         """Merge items from ``path`` to collection.
540         """
541
542         try:
543             new_object = vobject.readComponents(codecs.open(path,encoding='utf-8').read())
544             for new_ics in new_object:
545                 if new_ics.name == 'VCALENDAR':
546
547                     events = new_ics.vevent_list
548                     for ve in events:
549                         # Check for events with both dtstart and duration entries and
550                         # delete the duration one
551                         if ve.contents.has_key('dtstart') and ve.contents.has_key('duration'):
552                             del ve.contents['duration']
553                         new_ics.vevent_list = [ve]
554                         new_item = Item(new_ics.serialize().decode('utf-8'), None, path)
555                         self.import_item(new_item, path)
556                 else:
557                     new_item = Item(new_ics.serialize().decode('utf-8'), None, path)
558                     self.import_item(new_item, path)
559             return True
560         except Exception, ex:
561             self.log.exception("Failed to import: %s", path)
562             return False
563         
564     def write(self, headers=None, items=None):
565         return True
566
567     @property
568     def ctag(self):
569         self.scan_dir(False)
570         """Ctag from collection."""
571         return self._ctag
572
573     @property
574     def name(self):
575         """Collection name."""
576         return self.path.split(os.path.sep)[-1]
577
578     @property
579     def text(self):
580         """Collection as plain text."""
581         self.scan_dir(False)
582         _text = ""
583         for item in self.my_items:
584             _text = _text + item.text
585         return _text
586
587     @property
588     def color(self):
589         """Color."""
590         try:
591             return "#%s" % self.metadata.get('collection', 'color')
592         except (ConfigParser.NoSectionError, ConfigParser.NoOptionError, ValueError):
593             return None
594
595     @property
596     def headers(self):
597         """Find headers items in collection."""
598         return []
599
600     @property
601     def items(self):
602         """Get list of all items in collection."""
603         self.scan_dir(False)
604         return self.my_items
605
606     @property
607     def last_modified(self):
608         """Get the last time the collection has been modified.
609
610         The date is formatted according to rfc1123-5.2.14.
611
612         """
613         self.scan_dir(False)
614         return time.gmtime(self.mtime)
615
616     @property
617     def length(self):
618         return "%d" % len(self.text)