Code

Fix security-problem: If user hasn't permission on a message (notably
[roundup.git] / roundup / roundupdb.py
1 from __future__ import nested_scopes
2 #
3 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
4 # This module is free software, and you may redistribute it and/or modify
5 # under the same terms as Python, so long as this copyright message and
6 # disclaimer are retained in their original form.
7 #
8 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
9 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
10 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
11 # POSSIBILITY OF SUCH DAMAGE.
12 #
13 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
14 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
15 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
16 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
17 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
18 #
20 """Extending hyperdb with types specific to issue-tracking.
21 """
22 __docformat__ = 'restructuredtext'
24 import re, os, smtplib, socket, time, random
25 import cStringIO, base64, mimetypes
26 import os.path
27 import logging
28 from email import Encoders
29 from email.Utils import formataddr
30 from email.Header import Header
31 from email.MIMEText import MIMEText
32 from email.MIMEBase import MIMEBase
34 from roundup import password, date, hyperdb
35 from roundup.i18n import _
37 # MessageSendError is imported for backwards compatibility
38 from roundup.mailer import Mailer, MessageSendError, encode_quopri
40 class Database:
42     # remember the journal uid for the current journaltag so that:
43     # a. we don't have to look it up every time we need it, and
44     # b. if the journaltag disappears during a transaction, we don't barf
45     #    (eg. the current user edits their username)
46     journal_uid = None
47     def getuid(self):
48         """Return the id of the "user" node associated with the user
49         that owns this connection to the hyperdatabase."""
50         if self.journaltag is None:
51             return None
52         elif self.journaltag == 'admin':
53             # admin user may not exist, but always has ID 1
54             return '1'
55         else:
56             if (self.journal_uid is None or self.journal_uid[0] !=
57                     self.journaltag):
58                 uid = self.user.lookup(self.journaltag)
59                 self.journal_uid = (self.journaltag, uid)
60             return self.journal_uid[1]
62     def setCurrentUser(self, username):
63         """Set the user that is responsible for current database
64         activities.
65         """
66         self.journaltag = username
68     def isCurrentUser(self, username):
69         """Check if a given username equals the already active user.
70         """
71         return self.journaltag == username
73     def getUserTimezone(self):
74         """Return user timezone defined in 'timezone' property of user class.
75         If no such property exists return 0
76         """
77         userid = self.getuid()
78         timezone = None
79         try:
80             tz = self.user.get(userid, 'timezone')
81             date.get_timezone(tz)
82             timezone = tz
83         except KeyError:
84             pass
85         # If there is no class 'user' or current user doesn't have timezone
86         # property or that property is not set assume he/she lives in
87         # the timezone set in the tracker config.
88         if timezone is None:
89             timezone = self.config['TIMEZONE']
90         return timezone
92     def confirm_registration(self, otk):
93         props = self.getOTKManager().getall(otk)
94         for propname, proptype in self.user.getprops().items():
95             value = props.get(propname, None)
96             if value is None:
97                 pass
98             elif isinstance(proptype, hyperdb.Date):
99                 props[propname] = date.Date(value)
100             elif isinstance(proptype, hyperdb.Interval):
101                 props[propname] = date.Interval(value)
102             elif isinstance(proptype, hyperdb.Password):
103                 props[propname] = password.Password()
104                 props[propname].unpack(value)
106         # tag new user creation with 'admin'
107         self.journaltag = 'admin'
109         # create the new user
110         cl = self.user
112         props['roles'] = self.config.NEW_WEB_USER_ROLES
113         userid = cl.create(**props)
114         # clear the props from the otk database
115         self.getOTKManager().destroy(otk)
116         self.commit()
118         return userid
121     def log_debug(self, msg, *args, **kwargs):
122         """Log a message with level DEBUG."""
124         logger = self.get_logger()
125         logger.debug(msg, *args, **kwargs)
127     def log_info(self, msg, *args, **kwargs):
128         """Log a message with level INFO."""
130         logger = self.get_logger()
131         logger.info(msg, *args, **kwargs)
133     def get_logger(self):
134         """Return the logger for this database."""
136         # Because getting a logger requires acquiring a lock, we want
137         # to do it only once.
138         if not hasattr(self, '__logger'):
139             self.__logger = logging.getLogger('hyperdb')
141         return self.__logger
144 class DetectorError(RuntimeError):
145     """ Raised by detectors that want to indicate that something's amiss
146     """
147     pass
149 # deviation from spec - was called IssueClass
150 class IssueClass:
151     """This class is intended to be mixed-in with a hyperdb backend
152     implementation. The backend should provide a mechanism that
153     enforces the title, messages, files, nosy and superseder
154     properties:
156     - title = hyperdb.String(indexme='yes')
157     - messages = hyperdb.Multilink("msg")
158     - files = hyperdb.Multilink("file")
159     - nosy = hyperdb.Multilink("user")
160     - superseder = hyperdb.Multilink(classname)
161     """
163     # The tuple below does not affect the class definition.
164     # It just lists all names of all issue properties
165     # marked for message extraction tool.
166     #
167     # XXX is there better way to get property names into message catalog??
168     #
169     # Note that this list also includes properties
170     # defined in the classic template:
171     # assignedto, keyword, priority, status.
172     (
173         ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
174         ''"assignedto", ''"keyword", ''"priority", ''"status",
175         # following properties are common for all hyperdb classes
176         # they are listed here to keep things in one place
177         ''"actor", ''"activity", ''"creator", ''"creation",
178     )
180     # New methods:
181     def addmessage(self, nodeid, summary, text):
182         """Add a message to an issue's mail spool.
184         A new "msg" node is constructed using the current date, the user that
185         owns the database connection as the author, and the specified summary
186         text.
188         The "files" and "recipients" fields are left empty.
190         The given text is saved as the body of the message and the node is
191         appended to the "messages" field of the specified issue.
192         """
194     def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
195             from_address=None, cc=[], bcc=[]):
196         """Send a message to the members of an issue's nosy list.
198         The message is sent only to users on the nosy list who are not
199         already on the "recipients" list for the message.
201         These users are then added to the message's "recipients" list.
203         If 'msgid' is None, the message gets sent only to the nosy
204         list, and it's called a 'System Message'.
206         The "cc" argument indicates additional recipients to send the
207         message to that may not be specified in the message's recipients
208         list.
210         The "bcc" argument also indicates additional recipients to send the
211         message to that may not be specified in the message's recipients
212         list. These recipients will not be included in the To: or Cc:
213         address lists.
214         """
215         if msgid:
216             authid = self.db.msg.get(msgid, 'author')
217             recipients = self.db.msg.get(msgid, 'recipients', [])
218         else:
219             # "system message"
220             authid = None
221             recipients = []
223         sendto = []
224         bcc_sendto = []
225         seen_message = {}
226         for recipient in recipients:
227             seen_message[recipient] = 1
229         def add_recipient(userid, to):
230             """ make sure they have an address """
231             address = self.db.user.get(userid, 'address')
232             if address:
233                 to.append(address)
234                 recipients.append(userid)
236         def good_recipient(userid):
237             """ Make sure we don't send mail to either the anonymous
238                 user or a user who has already seen the message.
239                 Also check permissions on the message if not a system
240                 message: A user must have view permisson on content and
241                 files to be on the receiver list. We do *not* check the 
242                 author etc. for now.
243             """
244             allowed = True
245             if msgid:
246                 for prop in 'content', 'files':
247                     if prop in self.db.msg.properties:
248                         allowed = allowed and self.db.security.hasPermission(
249                             'View', userid, 'msg', prop, msgid)
250             return (userid and
251                     (self.db.user.get(userid, 'username') != 'anonymous') and
252                     allowed and not seen_message.has_key(userid))
254         # possibly send the message to the author, as long as they aren't
255         # anonymous
256         if (good_recipient(authid) and
257             (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
258              (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
259             add_recipient(authid, sendto)
261         if authid:
262             seen_message[authid] = 1
264         # now deal with the nosy and cc people who weren't recipients.
265         for userid in cc + self.get(nodeid, whichnosy):
266             if good_recipient(userid):
267                 add_recipient(userid, sendto)
269         # now deal with bcc people.
270         for userid in bcc:
271             if good_recipient(userid):
272                 add_recipient(userid, bcc_sendto)
274         if oldvalues:
275             note = self.generateChangeNote(nodeid, oldvalues)
276         else:
277             note = self.generateCreateNote(nodeid)
279         # If we have new recipients, update the message's recipients
280         # and send the mail.
281         if sendto or bcc_sendto:
282             if msgid is not None:
283                 self.db.msg.set(msgid, recipients=recipients)
284             self.send_message(nodeid, msgid, note, sendto, from_address,
285                 bcc_sendto)
287     # backwards compatibility - don't remove
288     sendmessage = nosymessage
290     def send_message(self, nodeid, msgid, note, sendto, from_address=None,
291             bcc_sendto=[]):
292         '''Actually send the nominated message from this node to the sendto
293            recipients, with the note appended.
294         '''
295         users = self.db.user
296         messages = self.db.msg
297         files = self.db.file
299         if msgid is None:
300             inreplyto = None
301             messageid = None
302         else:
303             inreplyto = messages.get(msgid, 'inreplyto')
304             messageid = messages.get(msgid, 'messageid')
306         # make up a messageid if there isn't one (web edit)
307         if not messageid:
308             # this is an old message that didn't get a messageid, so
309             # create one
310             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
311                                            self.classname, nodeid,
312                                            self.db.config.MAIL_DOMAIN)
313             if msgid is not None:
314                 messages.set(msgid, messageid=messageid)
316         # compose title
317         cn = self.classname
318         title = self.get(nodeid, 'title') or '%s message copy'%cn
320         # figure author information
321         if msgid:
322             authid = messages.get(msgid, 'author')
323         else:
324             authid = self.db.getuid()
325         authname = users.get(authid, 'realname')
326         if not authname:
327             authname = users.get(authid, 'username', '')
328         authaddr = users.get(authid, 'address', '')
330         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
331             authaddr = " <%s>" % formataddr( ('',authaddr) )
332         elif authaddr:
333             authaddr = ""
335         # make the message body
336         m = ['']
338         # put in roundup's signature
339         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
340             m.append(self.email_signature(nodeid, msgid))
342         # add author information
343         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
344             if msgid and len(self.get(nodeid, 'messages')) == 1:
345                 m.append(_("New submission from %(authname)s%(authaddr)s:")
346                     % locals())
347             elif msgid:
348                 m.append(_("%(authname)s%(authaddr)s added the comment:")
349                     % locals())
350             else:
351                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
352             m.append('')
354         # add the content
355         if msgid is not None:
356             m.append(messages.get(msgid, 'content', ''))
358         # get the files for this message
359         message_files = []
360         if msgid :
361             for fileid in messages.get(msgid, 'files') :
362                 # check the attachment size
363                 filename = self.db.filename('file', fileid, None)
364                 filesize = os.path.getsize(filename)
365                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
366                     message_files.append(fileid)
367                 else:
368                     base = self.db.config.TRACKER_WEB
369                     link = "".join((base, files.classname, fileid))
370                     filename = files.get(fileid, 'name')
371                     m.append(_("File '%(filename)s' not attached - "
372                         "you can download it from %(link)s.") % locals())
374         # add the change note
375         if note:
376             m.append(note)
378         # put in roundup's signature
379         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
380             m.append(self.email_signature(nodeid, msgid))
382         # figure the encoding
383         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
385         # construct the content and convert to unicode object
386         body = unicode('\n'.join(m), 'utf-8').encode(charset)
388         # make sure the To line is always the same (for testing mostly)
389         sendto.sort()
391         # make sure we have a from address
392         if from_address is None:
393             from_address = self.db.config.TRACKER_EMAIL
395         # additional bit for after the From: "name"
396         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
397         if from_tag:
398             from_tag = ' ' + from_tag
400         subject = '[%s%s] %s'%(cn, nodeid, title)
401         author = (authname + from_tag, from_address)
403         # send an individual message per recipient?
404         if self.db.config.NOSY_EMAIL_SENDING != 'single':
405             sendto = [[address] for address in sendto]
406         else:
407             sendto = [sendto]
409         tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
410         tracker_name = formataddr((tracker_name, from_address))
411         tracker_name = Header(tracker_name, charset)
413         # now send one or more messages
414         # TODO: I believe we have to create a new message each time as we
415         # can't fiddle the recipients in the message ... worth testing
416         # and/or fixing some day
417         first = True
418         for sendto in sendto:
419             # create the message
420             mailer = Mailer(self.db.config)
422             message = mailer.get_standard_message(sendto, subject, author,
423                 multipart=message_files)
425             # set reply-to to the tracker
426             message['Reply-To'] = tracker_name
428             # message ids
429             if messageid:
430                 message['Message-Id'] = messageid
431             if inreplyto:
432                 message['In-Reply-To'] = inreplyto
434             # Generate a header for each link or multilink to
435             # a class that has a name attribute
436             for propname, prop in self.getprops().items():
437                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
438                     continue
439                 cl = self.db.getclass(prop.classname)
440                 if not 'name' in cl.getprops():
441                     continue
442                 if isinstance(prop, hyperdb.Link):
443                     value = self.get(nodeid, propname)
444                     if value is None:
445                         continue
446                     values = [value]
447                 else:
448                     values = self.get(nodeid, propname)
449                     if not values:
450                         continue
451                 values = [cl.get(v, 'name') for v in values]
452                 values = ', '.join(values)
453                 header = "X-Roundup-%s-%s"%(self.classname, propname)
454                 try:
455                     message[header] = values.encode('ascii')
456                 except UnicodeError:
457                     message[header] = Header(values, charset)
459             if not inreplyto:
460                 # Default the reply to the first message
461                 msgs = self.get(nodeid, 'messages')
462                 # Assume messages are sorted by increasing message number here
463                 # If the issue is just being created, and the submitter didn't
464                 # provide a message, then msgs will be empty.
465                 if msgs and msgs[0] != nodeid:
466                     inreplyto = messages.get(msgs[0], 'messageid')
467                     if inreplyto:
468                         message['In-Reply-To'] = inreplyto
470             # attach files
471             if message_files:
472                 # first up the text as a part
473                 part = MIMEText(body)
474                 encode_quopri(part)
475                 message.attach(part)
477                 for fileid in message_files:
478                     name = files.get(fileid, 'name')
479                     mime_type = files.get(fileid, 'type')
480                     content = files.get(fileid, 'content')
481                     if mime_type == 'text/plain':
482                         try:
483                             content.decode('ascii')
484                         except UnicodeError:
485                             # the content cannot be 7bit-encoded.
486                             # use quoted printable
487                             # XXX stuffed if we know the charset though :(
488                             part = MIMEText(content)
489                             encode_quopri(part)
490                         else:
491                             part = MIMEText(content)
492                             part['Content-Transfer-Encoding'] = '7bit'
493                     else:
494                         # some other type, so encode it
495                         if not mime_type:
496                             # this should have been done when the file was saved
497                             mime_type = mimetypes.guess_type(name)[0]
498                         if mime_type is None:
499                             mime_type = 'application/octet-stream'
500                         main, sub = mime_type.split('/')
501                         part = MIMEBase(main, sub)
502                         part.set_payload(content)
503                         Encoders.encode_base64(part)
504                     part['Content-Disposition'] = 'attachment;\n filename="%s"'%name
505                     message.attach(part)
507             else:
508                 message.set_payload(body)
509                 encode_quopri(message)
511             if first:
512                 mailer.smtp_send(sendto + bcc_sendto, message.as_string())
513             else:
514                 mailer.smtp_send(sendto, message.as_string())
515             first = False
517     def email_signature(self, nodeid, msgid):
518         ''' Add a signature to the e-mail with some useful information
519         '''
520         # simplistic check to see if the url is valid,
521         # then append a trailing slash if it is missing
522         base = self.db.config.TRACKER_WEB
523         if (not isinstance(base , type('')) or
524             not (base.startswith('http://') or base.startswith('https://'))):
525             web = "Configuration Error: TRACKER_WEB isn't a " \
526                 "fully-qualified URL"
527         else:
528             if not base.endswith('/'):
529                 base = base + '/'
530             web = base + self.classname + nodeid
532         # ensure the email address is properly quoted
533         email = formataddr((self.db.config.TRACKER_NAME,
534             self.db.config.TRACKER_EMAIL))
536         line = '_' * max(len(web)+2, len(email))
537         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
540     def generateCreateNote(self, nodeid):
541         """Generate a create note that lists initial property values
542         """
543         cn = self.classname
544         cl = self.db.classes[cn]
545         props = cl.getprops(protected=0)
547         # list the values
548         m = []
549         prop_items = props.items()
550         prop_items.sort()
551         for propname, prop in prop_items:
552             value = cl.get(nodeid, propname, None)
553             # skip boring entries
554             if not value:
555                 continue
556             if isinstance(prop, hyperdb.Link):
557                 link = self.db.classes[prop.classname]
558                 if value:
559                     key = link.labelprop(default_to_id=1)
560                     if key:
561                         value = link.get(value, key)
562                 else:
563                     value = ''
564             elif isinstance(prop, hyperdb.Multilink):
565                 if value is None: value = []
566                 l = []
567                 link = self.db.classes[prop.classname]
568                 key = link.labelprop(default_to_id=1)
569                 if key:
570                     value = [link.get(entry, key) for entry in value]
571                 value.sort()
572                 value = ', '.join(value)
573             else:
574                 value = str(value)
575                 if '\n' in value:
576                     value = '\n'+self.indentChangeNoteValue(value)
577             m.append('%s: %s'%(propname, value))
578         m.insert(0, '----------')
579         m.insert(0, '')
580         return '\n'.join(m)
582     def generateChangeNote(self, nodeid, oldvalues):
583         """Generate a change note that lists property changes
584         """
585         if not isinstance(oldvalues, type({})):
586             raise TypeError("'oldvalues' must be dict-like, not %s."%
587                 type(oldvalues))
589         cn = self.classname
590         cl = self.db.classes[cn]
591         changed = {}
592         props = cl.getprops(protected=0)
594         # determine what changed
595         for key in oldvalues.keys():
596             if key in ['files','messages']:
597                 continue
598             if key in ('actor', 'activity', 'creator', 'creation'):
599                 continue
600             # not all keys from oldvalues might be available in database
601             # this happens when property was deleted
602             try:
603                 new_value = cl.get(nodeid, key)
604             except KeyError:
605                 continue
606             # the old value might be non existent
607             # this happens when property was added
608             try:
609                 old_value = oldvalues[key]
610                 if type(new_value) is type([]):
611                     new_value.sort()
612                     old_value.sort()
613                 if new_value != old_value:
614                     changed[key] = old_value
615             except:
616                 changed[key] = new_value
618         # list the changes
619         m = []
620         changed_items = changed.items()
621         changed_items.sort()
622         for propname, oldvalue in changed_items:
623             prop = props[propname]
624             value = cl.get(nodeid, propname, None)
625             if isinstance(prop, hyperdb.Link):
626                 link = self.db.classes[prop.classname]
627                 key = link.labelprop(default_to_id=1)
628                 if key:
629                     if value:
630                         value = link.get(value, key)
631                     else:
632                         value = ''
633                     if oldvalue:
634                         oldvalue = link.get(oldvalue, key)
635                     else:
636                         oldvalue = ''
637                 change = '%s -> %s'%(oldvalue, value)
638             elif isinstance(prop, hyperdb.Multilink):
639                 change = ''
640                 if value is None: value = []
641                 if oldvalue is None: oldvalue = []
642                 l = []
643                 link = self.db.classes[prop.classname]
644                 key = link.labelprop(default_to_id=1)
645                 # check for additions
646                 for entry in value:
647                     if entry in oldvalue: continue
648                     if key:
649                         l.append(link.get(entry, key))
650                     else:
651                         l.append(entry)
652                 if l:
653                     l.sort()
654                     change = '+%s'%(', '.join(l))
655                     l = []
656                 # check for removals
657                 for entry in oldvalue:
658                     if entry in value: continue
659                     if key:
660                         l.append(link.get(entry, key))
661                     else:
662                         l.append(entry)
663                 if l:
664                     l.sort()
665                     change += ' -%s'%(', '.join(l))
666             else:
667                 change = '%s -> %s'%(oldvalue, value)
668                 if '\n' in change:
669                     value = self.indentChangeNoteValue(str(value))
670                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
671                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
672                         "new": value, "old": oldvalue}
673             m.append('%s: %s'%(propname, change))
674         if m:
675             m.insert(0, '----------')
676             m.insert(0, '')
677         return '\n'.join(m)
679     def indentChangeNoteValue(self, text):
680         lines = text.rstrip('\n').split('\n')
681         lines = [ '  '+line for line in lines ]
682         return '\n'.join(lines)
684 # vim: set filetype=python sts=4 sw=4 et si :