X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fcgi%2Factions.py;h=d48fb595eb75514bd47c74a4bd8abbbfefec8466;hb=4d9945dfdc1630579b99ee992c7887600ddb7f3e;hp=ac43e4c0a91fe1164b6e1379bf01c695607738db;hpb=ac6d696f4e811655f68b3a5e869da9c4c62251c5;p=roundup.git diff --git a/roundup/cgi/actions.py b/roundup/cgi/actions.py index ac43e4c..d48fb59 100755 --- a/roundup/cgi/actions.py +++ b/roundup/cgi/actions.py @@ -1,12 +1,12 @@ -#$Id: actions.py,v 1.73 2008-08-18 05:04:01 richard Exp $ - -import re, cgi, StringIO, urllib, time, random, csv, codecs +import re, cgi, time, random, csv, codecs from roundup import hyperdb, token, date, password +from roundup.actions import Action as BaseAction from roundup.i18n import _ import roundup.exceptions from roundup.cgi import exceptions, templating from roundup.mailgw import uidFromAddress +from roundup.anypy import io_, urllib_ __all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction', 'EditCSVAction', 'EditItemAction', 'PassResetAction', @@ -54,17 +54,17 @@ class Action: if (self.permissionType and not self.hasPermission(self.permissionType)): info = {'action': self.name, 'classname': self.classname} - raise exceptions.Unauthorised, self._( + raise exceptions.Unauthorised(self._( 'You do not have permission to ' - '%(action)s the %(classname)s class.')%info + '%(action)s the %(classname)s class.')%info) _marker = [] - def hasPermission(self, permission, classname=_marker, itemid=None): + def hasPermission(self, permission, classname=_marker, itemid=None, property=None): """Check whether the user has 'permission' on the current class.""" if classname is self._marker: classname = self.client.classname return self.db.security.hasPermission(permission, self.client.userid, - classname=classname, itemid=itemid) + classname=classname, itemid=itemid, property=property) def gettext(self, msgid): """Return the localized translation of msgid""" @@ -80,23 +80,23 @@ class ShowAction(Action): def handle(self): """Show a node of a particular class/id.""" t = n = '' - for key in self.form.keys(): + for key in self.form: if self.typere.match(key): t = self.form[key].value.strip() elif self.numre.match(key): n = self.form[key].value.strip() if not t: - raise ValueError, self._('No type specified') + raise ValueError(self._('No type specified')) if not n: - raise exceptions.SeriousError, self._('No ID entered') + raise exceptions.SeriousError(self._('No ID entered')) try: int(n) except ValueError: d = {'input': n, 'classname': t} - raise exceptions.SeriousError, self._( - '"%(input)s" is not an ID (%(classname)s ID required)')%d + raise exceptions.SeriousError(self._( + '"%(input)s" is not an ID (%(classname)s ID required)')%d) url = '%s%s%s'%(self.base, t, n) - raise exceptions.Redirect, url + raise exceptions.Redirect(url) class RetireAction(Action): name = 'retire' @@ -104,30 +104,37 @@ class RetireAction(Action): def handle(self): """Retire the context item.""" - # if we want to view the index template now, then unset the nodeid + # ensure modification comes via POST + if self.client.env['REQUEST_METHOD'] != 'POST': + raise roundup.exceptions.Reject(self._('Invalid request')) + + # if we want to view the index template now, then unset the itemid # context info (a special-case for retire actions on the index page) - nodeid = self.nodeid + itemid = self.nodeid if self.template == 'index': self.client.nodeid = None # make sure we don't try to retire admin or anonymous if self.classname == 'user' and \ - self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'): - raise ValueError, self._( - 'You may not retire the admin or anonymous user') + self.db.user.get(itemid, 'username') in ('admin', 'anonymous'): + raise ValueError(self._( + 'You may not retire the admin or anonymous user')) + + # check permission + if not self.hasPermission('Retire', classname=self.classname, + itemid=itemid): + raise exceptions.Unauthorised(self._( + 'You do not have permission to retire %(class)s' + ) % {'class': self.classname}) # do the retire - self.db.getclass(self.classname).retire(nodeid) + self.db.getclass(self.classname).retire(itemid) self.db.commit() self.client.ok_message.append( self._('%(classname)s %(itemid)s has been retired')%{ - 'classname': self.classname.capitalize(), 'itemid': nodeid}) + 'classname': self.classname.capitalize(), 'itemid': itemid}) - def hasPermission(self, permission, classname=Action._marker, itemid=None): - if itemid is None: - itemid = self.nodeid - return Action.hasPermission(self, permission, classname, itemid) class SearchAction(Action): name = 'search' @@ -165,14 +172,14 @@ class SearchAction(Action): try: qid = self.db.query.lookup(old_queryname) if not self.hasPermission('Edit', 'query', itemid=qid): - raise exceptions.Unauthorised, self._( - "You do not have permission to edit queries") + raise exceptions.Unauthorised(self._( + "You do not have permission to edit queries")) self.db.query.set(qid, klass=self.classname, url=url) except KeyError: # create a query if not self.hasPermission('Create', 'query'): - raise exceptions.Unauthorised, self._( - "You do not have permission to store queries") + raise exceptions.Unauthorised(self._( + "You do not have permission to store queries")) qid = self.db.query.create(name=queryname, klass=self.classname, url=url) else: @@ -193,15 +200,15 @@ class SearchAction(Action): if old_queryname != self.db.query.get(qid, 'name'): continue if not self.hasPermission('Edit', 'query', itemid=qid): - raise exceptions.Unauthorised, self._( - "You do not have permission to edit queries") + raise exceptions.Unauthorised(self._( + "You do not have permission to edit queries")) self.db.query.set(qid, klass=self.classname, url=url, name=queryname) else: # create a query if not self.hasPermission('Create', 'query'): - raise exceptions.Unauthorised, self._( - "You do not have permission to store queries") + raise exceptions.Unauthorised(self._( + "You do not have permission to store queries")) qid = self.db.query.create(name=queryname, klass=self.classname, url=url, private_for=uid) @@ -217,7 +224,7 @@ class SearchAction(Action): def fakeFilterVars(self): """Add a faked :filter form variable for each filtering prop.""" cls = self.db.classes[self.classname] - for key in self.form.keys(): + for key in self.form: prop = cls.get_transitive_prop(key) if not prop: continue @@ -234,7 +241,7 @@ class SearchAction(Action): if isinstance(prop, hyperdb.String): v = self.form[key].value l = token.token_split(v) - if len(l) > 1 or l[0] != v: + if len(l) != 1 or l[0] != v: self.form.value.remove(self.form[key]) # replace the single value with the split list for v in l: @@ -258,7 +265,7 @@ class SearchAction(Action): def getFromForm(self, name): for key in ('@' + name, ':' + name): - if self.form.has_key(key): + if key in self.form: return self.form[key].value.strip() return '' @@ -275,15 +282,22 @@ class EditCSVAction(Action): The "rows" CGI var defines the CSV-formatted entries for the class. New nodes are identified by the ID 'X' (or any other non-existent ID) and removed lines are retired. - """ + # ensure modification comes via POST + if self.client.env['REQUEST_METHOD'] != 'POST': + raise roundup.exceptions.Reject(self._('Invalid request')) + + # figure the properties list for the class cl = self.db.classes[self.classname] - idlessprops = cl.getprops(protected=0).keys() - idlessprops.sort() - props = ['id'] + idlessprops + props_without_id = list(cl.getprops(protected=0)) + + # the incoming CSV data will always have the properties in colums + # sorted and starting with the "id" column + props_without_id.sort() + props = ['id'] + props_without_id # do the edit - rows = StringIO.StringIO(self.form['rows'].value) + rows = io_.StringIO(self.form['rows'].value) reader = csv.reader(rows) found = {} line = 0 @@ -294,25 +308,43 @@ class EditCSVAction(Action): if values == props: continue - # extract the nodeid - nodeid, values = values[0], values[1:] - found[nodeid] = 1 + # extract the itemid + itemid, values = values[0], values[1:] + found[itemid] = 1 # see if the node exists - if nodeid in ('x', 'X') or not cl.hasnode(nodeid): + if itemid in ('x', 'X') or not cl.hasnode(itemid): exists = 0 + + # check permission to create this item + if not self.hasPermission('Create', classname=self.classname): + raise exceptions.Unauthorised(self._( + 'You do not have permission to create %(class)s' + ) % {'class': self.classname}) + elif cl.hasnode(itemid) and cl.is_retired(itemid): + # If a CSV line just mentions an id and the corresponding + # item is retired, then the item is restored. + cl.restore(itemid) + continue else: exists = 1 # confirm correct weight - if len(idlessprops) != len(values): + if len(props_without_id) != len(values): self.client.error_message.append( self._('Not enough values on line %(line)s')%{'line':line}) return # extract the new values d = {} - for name, value in zip(idlessprops, values): + for name, value in zip(props_without_id, values): + # check permission to edit this property on this item + if exists and not self.hasPermission('Edit', itemid=itemid, + classname=self.classname, property=name): + raise exceptions.Unauthorised(self._( + 'You do not have permission to edit %(class)s' + ) % {'class': self.classname}) + prop = cl.properties[name] value = value.strip() # only add the property if it has a value @@ -321,7 +353,7 @@ class EditCSVAction(Action): if isinstance(prop, hyperdb.Multilink): value = value.split(':') elif isinstance(prop, hyperdb.Password): - value = password.Password(value) + value = password.Password(value, config=self.db.config) elif isinstance(prop, hyperdb.Interval): value = date.Interval(value) elif isinstance(prop, hyperdb.Date): @@ -341,15 +373,21 @@ class EditCSVAction(Action): # perform the edit if exists: # edit existing - cl.set(nodeid, **d) + cl.set(itemid, **d) else: # new node found[cl.create(**d)] = 1 # retire the removed entries - for nodeid in cl.list(): - if not found.has_key(nodeid): - cl.retire(nodeid) + for itemid in cl.list(): + if itemid not in found: + # check permission to retire this item + if not self.hasPermission('Retire', itemid=itemid, + classname=self.classname): + raise exceptions.Unauthorised(self._( + 'You do not have permission to retire %(class)s' + ) % {'class': self.classname}) + cl.retire(itemid) # all OK self.db.commit() @@ -368,12 +406,12 @@ class EditCommon(Action): links = {} for cn, nodeid, propname, vlist in all_links: numeric_id = int (nodeid or 0) - if not (numeric_id > 0 or all_props.has_key((cn, nodeid))): + if not (numeric_id > 0 or (cn, nodeid) in all_props): # link item to link to doesn't (and won't) exist continue for value in vlist: - if not all_props.has_key(value): + if value not in all_props: # link item to link to doesn't (and won't) exist continue deps.setdefault((cn, nodeid), []).append(value) @@ -385,19 +423,19 @@ class EditCommon(Action): # loop detection change = 0 while len(all_props) != len(done): - for needed in all_props.keys(): - if done.has_key(needed): + for needed in all_props: + if needed in done: continue tlist = deps.get(needed, []) for target in tlist: - if not done.has_key(target): + if target not in done: break else: done[needed] = 1 order.append(needed) change = 1 if not change: - raise ValueError, 'linking must not loop!' + raise ValueError('linking must not loop!') # now, edit / create m = [] @@ -411,7 +449,7 @@ class EditCommon(Action): # and some nice feedback for the user if props: - info = ', '.join(map(self._, props.keys())) + info = ', '.join(map(self._, props)) m.append( self._('%(class)s %(id)s %(properties)s edited ok') % {'class':cn, 'id':nodeid, 'properties':info}) @@ -432,18 +470,18 @@ class EditCommon(Action): % {'class':cn, 'id':newid}) # fill in new ids in links - if links.has_key(needed): + if needed in links: for linkcn, linkid, linkprop in links[needed]: props = all_props[(linkcn, linkid)] cl = self.db.classes[linkcn] propdef = cl.getprops()[linkprop] - if not props.has_key(linkprop): + if linkprop not in props: if linkid is None or linkid.startswith('-'): # linking to a new item if isinstance(propdef, hyperdb.Multilink): - props[linkprop] = [newid] + props[linkprop] = [nodeid] else: - props[linkprop] = newid + props[linkprop] = nodeid else: # linking to an existing item if isinstance(propdef, hyperdb.Multilink): @@ -451,7 +489,7 @@ class EditCommon(Action): existing.append(nodeid) props[linkprop] = existing else: - props[linkprop] = newid + props[linkprop] = nodeid return '
'.join(m) @@ -459,9 +497,9 @@ class EditCommon(Action): """Change the node based on the contents of the form.""" # check for permission if not self.editItemPermission(props, classname=cn, itemid=nodeid): - raise exceptions.Unauthorised, self._( + raise exceptions.Unauthorised(self._( 'You do not have permission to edit %(class)s' - ) % {'class': cn} + ) % {'class': cn}) # make the changes cl = self.db.classes[cn] @@ -471,9 +509,9 @@ class EditCommon(Action): """Create a node based on the contents of the form.""" # check for permission if not self.newItemPermission(props, classname=cn): - raise exceptions.Unauthorised, self._( + raise exceptions.Unauthorised(self._( 'You do not have permission to create %(class)s' - ) % {'class': cn} + ) % {'class': cn}) # create the node and return its id cl = self.db.classes[cn] @@ -486,26 +524,20 @@ class EditCommon(Action): _cn_marker = [] def editItemPermission(self, props, classname=_cn_marker, itemid=None): - """Determine whether the user has permission to edit this item. - - Base behaviour is to check the user can edit this class. If we're - editing the "user" class, users are allowed to edit their own details. - Unless it's the "roles" property, which requires the special Permission - "Web Roles". - """ - if self.classname == 'user': - if props.has_key('roles') and not self.hasPermission('Web Roles'): - raise exceptions.Unauthorised, self._( - "You do not have permission to edit user roles") - if self.isEditingSelf(): - return 1 + """Determine whether the user has permission to edit this item.""" if itemid is None: itemid = self.nodeid if classname is self._cn_marker: classname = self.classname - if self.hasPermission('Edit', itemid=itemid, classname=classname): - return 1 - return 0 + # The user must have permission to edit each of the properties + # being changed. + for p in props: + if not self.hasPermission('Edit', itemid=itemid, + classname=classname, property=p): + return 0 + # Since the user has permission to edit all of the properties, + # the edit is OK. + return 1 def newItemPermission(self, props, classname=None): """Determine whether the user has permission to create this item. @@ -513,15 +545,26 @@ class EditCommon(Action): Base behaviour is to check the user can edit this class. No additional property checks are made. """ + if not classname : classname = self.client.classname - return self.hasPermission('Create', classname=classname) + + if not self.hasPermission('Create', classname=classname): + return 0 + + # Check Create permission for each property, to avoid being able + # to set restricted ones on new item creation + for key in props: + if not self.hasPermission('Create', classname=classname, + property=key): + return 0 + return 1 class EditItemAction(EditCommon): def lastUserActivity(self): - if self.form.has_key(':lastactivity'): + if ':lastactivity' in self.form: d = date.Date(self.form[':lastactivity'].value) - elif self.form.has_key('@lastactivity'): + elif '@lastactivity' in self.form: d = date.Date(self.form['@lastactivity'].value) else: return None @@ -541,7 +584,7 @@ class EditItemAction(EditCommon): props, links = self.client.parsePropsFromForm() key = (self.classname, self.nodeid) # we really only collide for direct prop edit conflicts - return props[key].keys() + return list(props[key]) else: return [] @@ -559,6 +602,10 @@ class EditItemAction(EditCommon): See parsePropsFromForm and _editnodes for special variables. """ + # ensure modification comes via POST + if self.client.env['REQUEST_METHOD'] != 'POST': + raise roundup.exceptions.Reject(self._('Invalid request')) + user_activity = self.lastUserActivity() if user_activity: props = self.detectCollision(user_activity, self.lastNodeActivity()) @@ -587,12 +634,12 @@ class EditItemAction(EditCommon): # we will want to include index-page args in this URL too if self.nodeid is not None: url += self.nodeid - url += '?@ok_message=%s&@template=%s'%(urllib.quote(message), - urllib.quote(self.template)) + url += '?@ok_message=%s&@template=%s'%(urllib_.quote(message), + urllib_.quote(self.template)) if self.nodeid is None: req = templating.HTMLRequest(self.client) url += '&' + req.indexargs_url('', {})[1:] - raise exceptions.Redirect, url + raise exceptions.Redirect(url) class NewItemAction(EditCommon): def handle(self): @@ -601,6 +648,10 @@ class NewItemAction(EditCommon): This follows the same form as the EditItemAction, with the same special form values. ''' + # ensure modification comes via POST + if self.client.env['REQUEST_METHOD'] != 'POST': + raise roundup.exceptions.Reject(self._('Invalid request')) + # parse the props from the form try: props, links = self.client.parsePropsFromForm(create=1) @@ -623,9 +674,9 @@ class NewItemAction(EditCommon): self.db.commit() # redirect to the new item's page - raise exceptions.Redirect, '%s%s%s?@ok_message=%s&@template=%s' % ( - self.base, self.classname, self.nodeid, urllib.quote(messages), - urllib.quote(self.template)) + raise exceptions.Redirect('%s%s%s?@ok_message=%s&@template=%s' % ( + self.base, self.classname, self.nodeid, urllib_.quote(messages), + urllib_.quote(self.template))) class PassResetAction(Action): def handle(self): @@ -636,7 +687,7 @@ class PassResetAction(Action): """ otks = self.db.getOTKManager() - if self.form.has_key('otk'): + if 'otk' in self.form: # pull the rego information out of the otk database otk = self.form['otk'].value uid = otks.get(otk, 'uid', default=None) @@ -660,7 +711,7 @@ class PassResetAction(Action): # XXX we need to make the "default" page be able to display errors! try: # set the password - cl.set(uid, password=password.Password(newpw)) + cl.set(uid, password=password.Password(newpw, config=self.db.config)) # clear the props from the otk database otks.destroy(otk) self.db.commit() @@ -688,7 +739,7 @@ Your password is now: %(password)s return # no OTK, so now figure the user - if self.form.has_key('username'): + if 'username' in self.form: name = self.form['username'].value try: uid = self.db.user.lookup(name) @@ -696,7 +747,7 @@ Your password is now: %(password)s self.client.error_message.append(self._('Unknown username')) return address = self.db.user.get(uid, 'address') - elif self.form.has_key('address'): + elif 'address' in self.form: address = self.form['address'].value uid = uidFromAddress(self.db, ('', address), create=0) if not uid: @@ -747,7 +798,7 @@ class RegoCommon(Action): # nice message message = self._('You are now registered, welcome!') url = '%suser%s?@ok_message=%s'%(self.base, self.userid, - urllib.quote(message)) + urllib_.quote(message)) # redirect to the user's page (but not 302, as some email clients seem # to want to reload the page, or something) @@ -770,7 +821,7 @@ class ConfRegoAction(RegoCommon): class RegisterAction(RegoCommon, EditCommon): name = 'register' - permissionType = 'Create' + permissionType = 'Register' def handle(self): """Attempt to create a new user based on the contents of the form @@ -778,6 +829,10 @@ class RegisterAction(RegoCommon, EditCommon): Return 1 on successful login. """ + # ensure modification comes via POST + if self.client.env['REQUEST_METHOD'] != 'POST': + raise roundup.exceptions.Reject(self._('Invalid request')) + # parse the props from the form try: props, links = self.client.parsePropsFromForm(create=1) @@ -786,12 +841,6 @@ class RegisterAction(RegoCommon, EditCommon): % str(message)) return - # registration isn't allowed to supply roles - user_props = props[('user', None)] - if user_props.has_key('roles'): - raise exceptions.Unauthorised, self._( - "It is not permitted to supply roles at registration.") - # skip the confirmation step? if self.db.config['INSTANT_REGISTRATION']: # handle the create now @@ -816,7 +865,8 @@ class RegisterAction(RegoCommon, EditCommon): return self.finishRego() # generate the one-time-key and store the props for later - for propname, proptype in self.db.user.getprops().items(): + user_props = props[('user', None)] + for propname, proptype in self.db.user.getprops().iteritems(): value = user_props.get(propname, None) if value is None: pass @@ -867,7 +917,18 @@ reply's additional "Re:" is ok), self.db.commit() # redirect to the "you're almost there" page - raise exceptions.Redirect, '%suser?@template=rego_progress'%self.base + raise exceptions.Redirect('%suser?@template=rego_progress'%self.base) + + def newItemPermission(self, props, classname=None): + """Just check the "Register" permission. + """ + # registration isn't allowed to supply roles + if 'roles' in props: + raise exceptions.Unauthorised(self._( + "It is not permitted to supply roles at registration.")) + + # technically already checked, but here for clarity + return self.hasPermission('Register', classname=classname) class LogoutAction(Action): def handle(self): @@ -892,14 +953,18 @@ class LoginAction(Action): Sets up a session for the user which contains the login credentials. """ + # ensure modification comes via POST + if self.client.env['REQUEST_METHOD'] != 'POST': + raise roundup.exceptions.Reject(self._('Invalid request')) + # we need the username at a minimum - if not self.form.has_key('__login_name'): + if '__login_name' not in self.form: self.client.error_message.append(self._('Username required')) return # get the login info self.client.user = self.form['__login_name'].value - if self.form.has_key('__login_password'): + if '__login_password' in self.form: password = self.form['__login_password'].value else: password = '' @@ -916,36 +981,43 @@ class LoginAction(Action): # save user in session self.client.session_api.set(user=self.client.user) - if self.form.has_key('remember'): + if 'remember' in self.form: self.client.session_api.update(set_cookie=True, expire=24*3600*365) # If we came from someplace, go back there - if self.form.has_key('__came_from'): - raise exceptions.Redirect, self.form['__came_from'].value + if '__came_from' in self.form: + raise exceptions.Redirect(self.form['__came_from'].value) def verifyLogin(self, username, password): # make sure the user exists try: self.client.userid = self.db.user.lookup(username) except KeyError: - raise exceptions.LoginError, self._('Invalid login') + raise exceptions.LoginError(self._('Invalid login')) # verify the password if not self.verifyPassword(self.client.userid, password): - raise exceptions.LoginError, self._('Invalid login') + raise exceptions.LoginError(self._('Invalid login')) # Determine whether the user has permission to log in. # Base behaviour is to check the user has "Web Access". if not self.hasPermission("Web Access"): - raise exceptions.LoginError, self._( - "You do not have permission to login") + raise exceptions.LoginError(self._( + "You do not have permission to login")) - def verifyPassword(self, userid, password): - '''Verify the password that the user has supplied''' - stored = self.db.user.get(userid, 'password') - if password == stored: + def verifyPassword(self, userid, givenpw): + '''Verify the password that the user has supplied. + Optionally migrate to new password scheme if configured + ''' + db = self.db + stored = db.user.get(userid, 'password') + if givenpw == stored: + if db.config.WEB_MIGRATE_PASSWORDS and stored.needs_migration(): + newpw = password.Password(givenpw, config=db.config) + db.user.set(userid, password=newpw) + db.commit() return 1 - if not password and not stored: + if not givenpw and not stored: return 1 return 0 @@ -991,8 +1063,59 @@ class ExportCSVAction(Action): # and search for itemid in klass.filter(matches, filterspec, sort, group): - self.client._socket_op(writer.writerow, [str(klass.get(itemid, col)) for col in columns]) + row = [] + for name in columns: + # check permission to view this property on this item + if not self.hasPermission('View', itemid=itemid, + classname=request.classname, property=name): + raise exceptions.Unauthorised(self._( + 'You do not have permission to view %(class)s' + ) % {'class': request.classname}) + row.append(str(klass.get(itemid, name))) + self.client._socket_op(writer.writerow, row) return '\n' + +class Bridge(BaseAction): + """Make roundup.actions.Action executable via CGI request. + + Using this allows users to write actions executable from multiple frontends. + CGI Form content is translated into a dictionary, which then is passed as + argument to 'handle()'. XMLRPC requests have to pass this dictionary + directly. + """ + + def __init__(self, *args): + + # As this constructor is callable from multiple frontends, each with + # different Action interfaces, we have to look at the arguments to + # figure out how to complete construction. + if (len(args) == 1 and + hasattr(args[0], '__class__') and + args[0].__class__.__name__ == 'Client'): + self.cgi = True + self.execute = self.execute_cgi + self.client = args[0] + self.form = self.client.form + else: + self.cgi = False + + def execute_cgi(self): + args = {} + for key in self.form: + args[key] = self.form.getvalue(key) + self.permission(args) + return self.handle(args) + + def permission(self, args): + """Raise Unauthorised if the current user is not allowed to execute + this action. Users may override this method.""" + + pass + + def handle(self, args): + + raise NotImplementedError + # vim: set filetype=python sts=4 sw=4 et si :