root / pykota / trunk / pykota / tool.py @ 3545

Revision 3545, 21.2 kB (checked in by jerome, 14 years ago)

Ensures that all command lines are properly encoded before calling
os.system() on them from the cupspykota backend wrapper.
TODO : check command line tools for the same potential problem.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
Line 
1# -*- coding: utf-8 -*-
2#
3# PyKota : Print Quotas for CUPS
4#
5# (c) 2003-2009 Jerome Alet <alet@librelogiciel.com>
6# This program is free software: you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation, either version 3 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program.  If not, see <http://www.gnu.org/licenses/>.
18#
19# $Id$
20#
21#
22
23"""This module defines the base classes for PyKota command line tools."""
24
25import sys
26import os
27import pwd
28import fnmatch
29import getopt
30import smtplib
31import locale
32import socket
33import time
34from email.MIMEText import MIMEText
35from email.Header import Header
36import email.Utils
37
38from mx import DateTime
39
40from pykota import utils
41from pykota.errors import PyKotaCommandLineError
42from pykota import config, storage, logger
43from pykota.version import __version__, __author__, __years__, __gplblurb__
44
45class Tool :
46    """Base class for tools with no database access."""
47    def __init__(self, doc="PyKota v%(__version__)s (c) %(__years__)s %(__author__)s") :
48        """Initializes the command line tool."""
49        self.debug = True # in case of early failure
50        self.logger = logger.openLogger("stderr")
51
52        # Saves a copy of the locale settings
53        (self.language, self.charset) = locale.getlocale()
54        if not self.language :
55            self.language = "C"
56        if not self.charset :
57            self.charset = "UTF-8"
58
59        # pykota specific stuff
60        self.documentation = doc
61
62        # Extract the effective username
63        uid = os.geteuid()
64        try :
65            self.effectiveUserName = pwd.getpwuid(uid)[0]
66        except (KeyError, IndexError), msg :
67            self.printInfo(_("Strange problem with uid(%s) : %s") % (uid, msg), "warn")
68            self.effectiveUserName = os.getlogin()
69
70    def deferredInit(self) :
71        """Deferred initialization."""
72        confdir = os.environ.get("PYKOTA_HOME")
73        environHome = True
74        missingUser = False
75        if confdir is None :
76            environHome = False
77            # check for config files in the 'pykota' user's home directory.
78            try :
79                self.pykotauser = pwd.getpwnam("pykota")
80                confdir = self.pykotauser[5]
81            except KeyError :
82                self.pykotauser = None
83                confdir = "/etc/pykota"
84                missingUser = True
85
86        self.config = config.PyKotaConfig(confdir)
87        self.debug = self.config.getDebug()
88        self.smtpserver = self.config.getSMTPServer()
89        self.maildomain = self.config.getMailDomain()
90        self.logger = logger.openLogger(self.config.getLoggingBackend())
91
92        # TODO : We NEED this here, even when not in an accounting filter/backend
93        self.softwareJobSize = 0
94        self.softwareJobPrice = 0.0
95
96        if environHome :
97            self.printInfo("PYKOTA_HOME environment variable is set. Configuration files were searched in %s" % confdir, "info")
98        else :
99            if missingUser :
100                self.printInfo("The 'pykota' system account is missing. Configuration files were searched in %s instead." % confdir, "warn")
101
102        self.logdebug("Language in use : %s" % self.language)
103        self.logdebug("Charset in use : %s" % self.charset)
104
105        arguments = " ".join(['"%s"' % arg for arg in sys.argv])
106        self.logdebug("Command line arguments : %s" % arguments)
107
108    def display(self, message) :
109        """Display a message after ensuring the correct charset is used."""
110        sys.stdout.write(message.encode(self.charset,
111                                            "replace"))
112
113    def logdebug(self, message) :
114        """Logs something to debug output if debug is enabled."""
115        if self.debug :
116            self.logger.log_message(message.encode(self.charset, \
117                                                   "replace"), \
118                                    "debug")
119
120    def printInfo(self, message, level="info") :
121        """Sends a message to standard error."""
122        sys.stderr.write("%s: %s\n" % (level.upper(), \
123                                       message.encode(self.charset, \
124                                                      "replace")))
125        sys.stderr.flush()
126
127    def adminOnly(self, restricted=True) :
128        """Raises an exception if the user is not a PyKota administrator."""
129        if restricted and not self.config.isAdmin :
130            raise PyKotaCommandLineError, "%s : %s" % (pwd.getpwuid(os.geteuid())[0], _("You're not allowed to use this command."))
131
132    def matchString(self, s, patterns) :
133        """Returns True if the string s matches one of the patterns, else False."""
134        if not patterns :
135            return True # No pattern, always matches.
136        else :
137            for pattern in patterns :
138                if fnmatch.fnmatchcase(s, pattern) :
139                    return True
140            return False
141
142    def sanitizeNames(self, names, isgroups) :
143        """Sanitize users and groups names if needed."""
144        if not self.config.isAdmin :
145            username = pwd.getpwuid(os.geteuid())[0]
146            if isgroups :
147                user = self.storage.getUser(username)
148                if user.Exists :
149                    return [ g.Name for g in self.storage.getUserGroups(user) ]
150            return [ username ]
151        return names
152
153    def display_version_and_quit(self) :
154        """Displays version number, then exists successfully."""
155        try :
156            self.clean()
157        except AttributeError :
158            pass
159        self.display("%s\n" % __version__)
160        sys.exit(0)
161
162    def display_usage_and_quit(self) :
163        """Displays command line usage, then exists successfully."""
164        try :
165            self.clean()
166        except AttributeError :
167            pass
168        self.display("%s\n" % (_(self.documentation) % globals()))
169        self.display("%s\n\n" % __gplblurb__)
170        self.display("%s %s\n" % (_("Please report bugs to :"), __author__))
171        sys.exit(0)
172
173    def crashed(self, message="Bug in PyKota") :
174        """Outputs a crash message, and optionally sends it to software author."""
175        msg = utils.crashed(message)
176        fullmessage = "========== Traceback :\n\n%s\n\n========== sys.argv :\n\n%s\n\n========== Environment :\n\n%s\n" % \
177                        (msg, \
178                         "\n".join(["    %s" % repr(a) for a in sys.argv]), \
179                         "\n".join(["    %s=%s" % (k, repr(v)) for (k, v) in os.environ.items()]))
180        try :
181            crashrecipient = self.config.getCrashRecipient()
182            if crashrecipient :
183                admin = self.config.getAdminMail("global") # Nice trick, isn't it ?
184                server = smtplib.SMTP(self.smtpserver)
185                msg = MIMEText(fullmessage.encode(self.charset, "replace"), _charset=self.charset)
186                msg["Subject"] = Header("PyKota v%s crash traceback !" \
187                                        % __version__, charset=self.charset, errors="replace")
188                msg["From"] = admin
189                msg["To"] = crashrecipient
190                msg["Cc"] = admin
191                msg["Date"] = email.Utils.formatdate(localtime=True)
192                server.sendmail(admin, [admin, crashrecipient], msg.as_string())
193                server.quit()
194        except :
195            self.printInfo("PyKota double crash !", "error")
196            raise
197        return fullmessage
198
199    def parseCommandline(self, argv, short, long, allownothing=0) :
200        """Parses the command line, controlling options."""
201        # split options in two lists: those which need an argument, those which don't need any
202        short = "%sA:" % short
203        long.append("arguments=")
204        withoutarg = []
205        witharg = []
206        lgs = len(short)
207        i = 0
208        while i < lgs :
209            ii = i + 1
210            if (ii < lgs) and (short[ii] == ':') :
211                # needs an argument
212                witharg.append(short[i])
213                ii = ii + 1 # skip the ':'
214            else :
215                # doesn't need an argument
216                withoutarg.append(short[i])
217            i = ii
218
219        for option in long :
220            if option[-1] == '=' :
221                # needs an argument
222                witharg.append(option[:-1])
223            else :
224                # doesn't need an argument
225                withoutarg.append(option)
226
227        # then we parse the command line
228        done = 0
229        while not done :
230            # we begin with all possible options unset
231            parsed = {}
232            for option in withoutarg + witharg :
233                parsed[option] = None
234            args = []       # to not break if something unexpected happened
235            try :
236                options, args = getopt.getopt(argv, short, long)
237                if options :
238                    for (o, v) in options :
239                        # we skip the '-' chars
240                        lgo = len(o)
241                        i = 0
242                        while (i < lgo) and (o[i] == '-') :
243                            i = i + 1
244                        o = o[i:]
245                        if o in witharg :
246                            # needs an argument : set it
247                            parsed[o] = v
248                        elif o in withoutarg :
249                            # doesn't need an argument : boolean
250                            parsed[o] = 1
251                        else :
252                            # should never occur
253                            raise PyKotaCommandLineError, "Unexpected problem when parsing command line"
254                elif (not args) and (not allownothing) and sys.stdin.isatty() : # no option and no argument, we display help if we are a tty
255                    self.display_usage_and_quit()
256            except getopt.error, msg :
257                raise PyKotaCommandLineError, str(msg)
258            else :
259                if parsed["arguments"] or parsed["A"] :
260                    # arguments are in a file, we ignore all other arguments
261                    # and reset the list of arguments to the lines read from
262                    # the file.
263                    argsfile = open(parsed["arguments"] or parsed["A"], "r") # TODO : charset decoding
264                    argv = [ l.strip() for l in argsfile.readlines() ]
265                    argsfile.close()
266                    for i in range(len(argv)) :
267                        argi = argv[i]
268                        if argi.startswith('"') and argi.endswith('"') :
269                            argv[i] = argi[1:-1]
270                else :
271                    done = 1
272        return (parsed, args)
273
274class PyKotaTool(Tool) :
275    """Base class for all PyKota command line tools."""
276    def deferredInit(self) :
277        """Deferred initialization."""
278        Tool.deferredInit(self)
279        self.storage = storage.openConnection(self)
280        if self.config.isAdmin : # TODO : We don't know this before, fix this !
281            self.logdebug("Beware : running as a PyKota administrator !")
282        else :
283            self.logdebug("Don't Panic : running as a mere mortal !")
284
285    def clean(self) :
286        """Ensures that the database is closed."""
287        try :
288            self.storage.close()
289        except (TypeError, NameError, AttributeError) :
290            pass
291
292    def isValidName(self, name) :
293        """Checks if a user or printer name is valid."""
294        invalidchars = "/@?*,;&|"
295        for c in list(invalidchars) :
296            if c in name :
297                return False
298        return True
299
300    def _checkUserPQuota(self, userpquota) :
301        """Checks the user quota on a printer and deny or accept the job."""
302        # then we check the user's own quota
303        # if we get there we are sure that policy is not EXTERNAL
304        user = userpquota.User
305        printer = userpquota.Printer
306        enforcement = self.config.getPrinterEnforcement(printer.Name)
307        self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
308        (policy, dummy) = self.config.getPrinterPolicy(userpquota.Printer.Name)
309        if not userpquota.Exists :
310            # Unknown userquota
311            if policy == "ALLOW" :
312                action = "POLICY_ALLOW"
313            else :
314                action = "POLICY_DENY"
315            self.printInfo(_("Unable to match user %s on printer %s, applying default policy (%s)") % (user.Name, printer.Name, action))
316        else :
317            pagecounter = int(userpquota.PageCounter or 0)
318            if enforcement == "STRICT" :
319                pagecounter += self.softwareJobSize
320            if userpquota.SoftLimit is not None :
321                softlimit = int(userpquota.SoftLimit)
322                if pagecounter < softlimit :
323                    action = "ALLOW"
324                else :
325                    if userpquota.HardLimit is None :
326                        # only a soft limit, this is equivalent to having only a hard limit
327                        action = "DENY"
328                    else :
329                        hardlimit = int(userpquota.HardLimit)
330                        if softlimit <= pagecounter < hardlimit :
331                            now = DateTime.now()
332                            if userpquota.DateLimit is not None :
333                                datelimit = DateTime.ISO.ParseDateTime(str(userpquota.DateLimit)[:19])
334                            else :
335                                datelimit = now + self.config.getGraceDelay(printer.Name)
336                                userpquota.setDateLimit(datelimit)
337                            if now < datelimit :
338                                action = "WARN"
339                            else :
340                                action = "DENY"
341                        else :
342                            action = "DENY"
343            else :
344                if userpquota.HardLimit is not None :
345                    # no soft limit, only a hard one.
346                    hardlimit = int(userpquota.HardLimit)
347                    if pagecounter < hardlimit :
348                        action = "ALLOW"
349                    else :
350                        action = "DENY"
351                else :
352                    # Both are unset, no quota, i.e. accounting only
353                    action = "ALLOW"
354        return action
355
356    def checkGroupPQuota(self, grouppquota) :
357        """Checks the group quota on a printer and deny or accept the job."""
358        group = grouppquota.Group
359        printer = grouppquota.Printer
360        enforcement = self.config.getPrinterEnforcement(printer.Name)
361        self.logdebug("Checking group %s's quota on printer %s" % (group.Name, printer.Name))
362        if group.LimitBy and (group.LimitBy.lower() == "balance") :
363            val = group.AccountBalance or 0.0
364            if enforcement == "STRICT" :
365                val -= self.softwareJobPrice # use precomputed size.
366            balancezero = self.config.getBalanceZero()
367            if val <= balancezero :
368                action = "DENY"
369            elif val <= self.config.getPoorMan() :
370                action = "WARN"
371            else :
372                action = "ALLOW"
373            if (enforcement == "STRICT") and (val == balancezero) :
374                action = "WARN" # we can still print until account is 0
375        else :
376            val = grouppquota.PageCounter or 0
377            if enforcement == "STRICT" :
378                val += int(self.softwareJobSize) # TODO : this is not a fix, problem is elsewhere in grouppquota.PageCounter
379            if grouppquota.SoftLimit is not None :
380                softlimit = int(grouppquota.SoftLimit)
381                if val < softlimit :
382                    action = "ALLOW"
383                else :
384                    if grouppquota.HardLimit is None :
385                        # only a soft limit, this is equivalent to having only a hard limit
386                        action = "DENY"
387                    else :
388                        hardlimit = int(grouppquota.HardLimit)
389                        if softlimit <= val < hardlimit :
390                            now = DateTime.now()
391                            if grouppquota.DateLimit is not None :
392                                datelimit = DateTime.ISO.ParseDateTime(str(grouppquota.DateLimit)[:19])
393                            else :
394                                datelimit = now + self.config.getGraceDelay(printer.Name)
395                                grouppquota.setDateLimit(datelimit)
396                            if now < datelimit :
397                                action = "WARN"
398                            else :
399                                action = "DENY"
400                        else :
401                            action = "DENY"
402            else :
403                if grouppquota.HardLimit is not None :
404                    # no soft limit, only a hard one.
405                    hardlimit = int(grouppquota.HardLimit)
406                    if val < hardlimit :
407                        action = "ALLOW"
408                    else :
409                        action = "DENY"
410                else :
411                    # Both are unset, no quota, i.e. accounting only
412                    action = "ALLOW"
413        return action
414
415    def checkUserPQuota(self, userpquota) :
416        """Checks the user quota on a printer and all its parents and deny or accept the job."""
417        user = userpquota.User
418        printer = userpquota.Printer
419
420        # indicates that a warning needs to be sent
421        warned = False
422
423        # first we check any group the user is a member of
424        for group in self.storage.getUserGroups(user) :
425            # No need to check anything if the group is in noquota mode
426            if group.LimitBy != "noquota" :
427                grouppquota = self.storage.getGroupPQuota(group, printer)
428                # for the printer and all its parents
429                for gpquota in [ grouppquota ] + grouppquota.ParentPrintersGroupPQuota :
430                    if gpquota.Exists :
431                        action = self.checkGroupPQuota(gpquota)
432                        if action == "DENY" :
433                            return action
434                        elif action == "WARN" :
435                            warned = True
436
437        # Then we check the user's account balance
438        # if we get there we are sure that policy is not EXTERNAL
439        (policy, dummy) = self.config.getPrinterPolicy(printer.Name)
440        if user.LimitBy and (user.LimitBy.lower() == "balance") :
441            self.logdebug("Checking account balance for user %s" % user.Name)
442            if user.AccountBalance is None :
443                if policy == "ALLOW" :
444                    action = "POLICY_ALLOW"
445                else :
446                    action = "POLICY_DENY"
447                self.printInfo(_("Unable to find user %s's account balance, applying default policy (%s) for printer %s") % (user.Name, action, printer.Name))
448                return action
449            else :
450                if user.OverCharge == 0.0 :
451                    self.printInfo(_("User %s will not be charged for printing.") % user.Name)
452                    action = "ALLOW"
453                else :
454                    val = float(user.AccountBalance or 0.0)
455                    enforcement = self.config.getPrinterEnforcement(printer.Name)
456                    if enforcement == "STRICT" :
457                        val -= self.softwareJobPrice # use precomputed size.
458                    balancezero = self.config.getBalanceZero()
459                    if val <= balancezero :
460                        action = "DENY"
461                    elif val <= self.config.getPoorMan() :
462                        action = "WARN"
463                    else :
464                        action = "ALLOW"
465                    if (enforcement == "STRICT") and (val == balancezero) :
466                        action = "WARN" # we can still print until account is 0
467                return action
468        else :
469            # Then check the user quota on current printer and all its parents.
470            policyallowed = False
471            for upquota in [ userpquota ] + userpquota.ParentPrintersUserPQuota :
472                action = self._checkUserPQuota(upquota)
473                if action in ("DENY", "POLICY_DENY") :
474                    return action
475                elif action == "WARN" :
476                    warned = True
477                elif action == "POLICY_ALLOW" :
478                    policyallowed = True
479            if warned :
480                return "WARN"
481            elif policyallowed :
482                return "POLICY_ALLOW"
483            else :
484                return "ALLOW"
485
486    def externalMailTo(self, cmd, action, user, printer, message) :
487        """Warns the user with an external command."""
488        cmd = cmd.encode(self.charset, "replace")
489        message = message.encode(self.charset, "replace")
490        username = user.Name
491        printername = printer.Name
492        email = user.Email or user.Name
493        if "@" not in email :
494            email = "%s@%s" % (email, self.maildomain or self.smtpserver)
495        os.system(cmd % locals())
496
497    def formatCommandLine(self, cmd, user, printer) :
498        """Executes an external command."""
499        username = user.Name
500        printername = printer.Name
501        return cmd % locals()
Note: See TracBrowser for help on using the browser.