diff --git a/auth/__init__.py b/auth/__init__.py index e69de29bb..7aa5bd65a 100755 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -0,0 +1,6 @@ +# coding: utf-8 +from modoboa.lib import parameters +from django.utils.translation import ugettext as _ + +parameters.register_admin("SECRET_KEY", type="string", deflt="abcdefghijklmnop", + help=_("Key used to encrypt/decrypt passwords")) diff --git a/auth/lib.py b/auth/lib.py new file mode 100644 index 000000000..97c340792 --- /dev/null +++ b/auth/lib.py @@ -0,0 +1,24 @@ +# coding: utf-8 + +from Crypto.Cipher import AES +import base64 +from modoboa.lib import parameters + +def encrypt(clear): + obj = AES.new(parameters.get_admin("SECRET_KEY"), + AES.MODE_ECB) + if len(clear) % AES.block_size: + clear += "\0" * (AES.block_size - len(clear) % AES.block_size) + ciph = obj.encrypt(clear) + ciph = base64.b64encode(ciph) + return ciph + +def decrypt(ciph): + obj = AES.new(parameters.get_admin("SECRET_KEY"), AES.MODE_ECB) + ciph = base64.b64decode(ciph) + clear = obj.decrypt(ciph) + return clear.rstrip('\0') + +def get_password(request): + return decrypt(request.session["password"]) + diff --git a/auth/views.py b/auth/views.py index b503c51aa..8369269f3 100755 --- a/auth/views.py +++ b/auth/views.py @@ -8,6 +8,7 @@ from django.utils.translation import ugettext as _ from modoboa.lib import events import modoboa.admin from forms import LoginForm +from modoboa.auth.lib import * def dologin(request): error = None @@ -19,6 +20,9 @@ def dologin(request): if user.is_active: login(request, user) + if request.user.id != 1: + request.session["password"] = encrypt(request.POST["password"]) + events.raiseEvent("UserLogin", request=request, username=request.POST["username"], password=request.POST["password"]) diff --git a/extensions/sievefilters/__init__.py b/extensions/sievefilters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/sievefilters/digest_md5.py b/extensions/sievefilters/digest_md5.py new file mode 100644 index 000000000..75c7aa0ed --- /dev/null +++ b/extensions/sievefilters/digest_md5.py @@ -0,0 +1,63 @@ +# coding: utf-8 + +import base64 +import hashlib +import binascii +import re +import random + +class DigestMD5(object): + def __init__(self, challenge, digesturi): + self.__digesturi = digesturi + self.__challenge = challenge + + self.__params = {} + pexpr = re.compile('(\w+)="(.+)"') + for elt in base64.b64decode(challenge).split(","): + m = pexpr.match(elt) + if m is None: + continue + self.__params[m.group(1)] = m.group(2) + + def __make_cnonce(self): + ret = "" + for i in xrange(12): + ret += chr(random.randint(0, 0xff)) + return base64.b64encode(ret) + + def __digest(self, value): + return hashlib.md5(value).digest() + + def __hexdigest(self, value): + return binascii.hexlify(hashlib.md5(value).digest()) + + def __make_response(self, username, password, check=False): + a1 = "%s:%s:%s" % (self.__digest("%s:%s:%s" % (username, self.realm, password)), + self.__params["nonce"], self.cnonce) + if check: + a2 = ":%s" % self.__digesturi + else: + a2 = "AUTHENTICATE:%s" % self.__digesturi + resp = "%s:%s:00000001:%s:auth:%s" \ + % (self.__hexdigest(a1), self.__params["nonce"], + self.cnonce, self.__hexdigest(a2)) + + return self.__hexdigest(resp) + + def response(self, username, password): + self.realm = self.__params["realm"] if self.__params.has_key("realm") else "" + self.cnonce = self.__make_cnonce() + respvalue = self.__make_response(username, password) + + dgres = 'username="%s",%snonce="%s",cnonce="%s",nc=00000001,qop=auth,' \ + 'digest-uri="%s",response=%s' \ + % (username, ('realm="%s",' % self.realm) if len(self.realm) else "", + self.__params["nonce"], self.cnonce, self.__digesturi, respvalue) + + return base64.b64encode(dgres) + + def check_last_challenge(self, username, password, value): + challenge = base64.b64decode(value.strip('"')) + return challenge == \ + ("rspauth=%s" % self.__make_response(username, password, True)) + diff --git a/extensions/sievefilters/forms.py b/extensions/sievefilters/forms.py new file mode 100644 index 000000000..87167010a --- /dev/null +++ b/extensions/sievefilters/forms.py @@ -0,0 +1,10 @@ +from django import forms +from django.utils.translation import ugettext as _ +from modoboa.admin.templatetags.admin_extras import gender + +class FiltersSetForm(forms.Form): + name = forms.CharField() + active = forms.BooleanField(label=gender("Active", "m"), required=False, + initial=False, + help_text=_("Check to activate this filters set")) + diff --git a/extensions/sievefilters/lib.py b/extensions/sievefilters/lib.py new file mode 100644 index 000000000..e91acd14b --- /dev/null +++ b/extensions/sievefilters/lib.py @@ -0,0 +1,65 @@ +# coding: utf-8 + +from django.utils.translation import ugettext as _ +from managesieve import ManageSieveClient, Error +from modoboa.lib import ConnectionsManager, ConnectionError, parameters + +class SieveClientError(Exception): + pass + +class SieveClient(object): + __metaclass__ = ConnectionsManager + + def __init__(self, user=None, password=None): + ret, msg = self.login(user, password) + if not ret: + raise ConnectionError(msg) + + def login(self, user, password): + self.msc = ManageSieveClient(parameters.get_admin("SERVER"), + int(parameters.get_admin("PORT")), + debug=False) + use_starttls = True if parameters.get_admin("STARTTLS") == "yes" else False + authmech = parameters.get_admin("AUTHENTICATION_MECH") + if authmech == "AUTO": + authmech = None + if not self.msc.connect(user, password, use_starttls, authmech): + return False, _("Connection to MANAGESIEVE server failed, check your configuration") + return True, None + + def refresh(self, user, password): + try: + self.msc.capability() + except Error, e: + pass + else: + return + ret, msg = self.login(user, password) + if not ret: + raise ConnectionError(msg) + + def listscripts(self): + return self.msc.listscripts() + + def getscript(self, name): + content = self.msc.getscript(name) + if content is None: + raise SieveClientError(self.msc.errmsg) + return content + + def pushscript(self, name, content, active=False): + if not self.msc.havespace(name, len(content)): + error = "%s (%s)" % (_("Not enough space on server"), self.msc.errmsg) + raise SieveClientError(error) + if not self.msc.putscript(name, content): + raise SieveClientError(self.msc.errmsg) + if active and not self.msc.setactive(name): + raise SieveClientError(self.msc.errmsg) + + def deletescript(self, name): + if not self.msc.deletescript(name): + raise SieveClientError(self.msc.errmsg) + + def activatescript(self, name): + if not self.msc.setactive(name): + raise SieveClientError(self.msc.errmsg) diff --git a/extensions/sievefilters/main.py b/extensions/sievefilters/main.py new file mode 100644 index 000000000..094ad30c6 --- /dev/null +++ b/extensions/sievefilters/main.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +from django.conf.urls.defaults import include +from modoboa.lib import events, parameters, static_url +from managesieve import SUPPORTED_AUTH_MECHS + +def init(): + events.register("UserMenuDisplay", menu) + + parameters.register_admin("SERVER", type="string", + deflt="127.0.0.1", + help=_("Address of your MANAGESIEVE server")) + parameters.register_admin("PORT", type="int", deflt="2000", + help=_("Listening port of your MANAGESIEVE server")) + parameters.register_admin("STARTTLS", type="list_yesno", deflt="no", + help=_("Use the STARTTLS extension")) + + values = [('AUTO', 'auto')] + for m in SUPPORTED_AUTH_MECHS: + values += [(m, m.lower())] + parameters.register_admin("AUTHENTICATION_MECH", type="list", deflt="auto", + values=values, + help=_("Prefered authentication mechanism")) + +def destroy(): + events.unregister("UserMenuDisplay", menu) + parameters.unregister_app("sievefilters") + +def infos(): + return { + "name" : "Sieve filters", + "version" : "1.0", + "description" : _("Plugin to easily create server-side filters") + } + +def urls(prefix): + return (r'^%ssfilters/' % prefix, + include('modoboa.extensions.sievefilters.urls')) + +def menu(**kwargs): + import views + + if kwargs["target"] != "options_menu": + return [] + return [ + {"name" : "sievefilters", + "label" : _("Message filters"), + "url" : reverse(views.index), + "img" : static_url("pics/filters.png")} + ] diff --git a/extensions/sievefilters/managesieve.py b/extensions/sievefilters/managesieve.py new file mode 100644 index 000000000..2b6e22b31 --- /dev/null +++ b/extensions/sievefilters/managesieve.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python +# coding: utf-8 + +"""MANAGESIEVE client. + +A protocol for securely managing Sieve scripts on a remote server. +This protocol allows a user to have multiple scripts, and also alerts +a user to syntactically flawed scripts. + +Implementation based on . +""" + +import sys, os +import socket +import re +import base64 +import ssl + +from digest_md5 import DigestMD5 + +CRLF = '\r\n' + +KNOWN_CAPABILITIES = ["IMPLEMENTATION", "SASL", "SIEVE", + "STARTTLS", "NOTIFY", "LANGUAGE", + "RENAME", "NOOP", "UNAUTHENTICATE"] + +SUPPORTED_AUTH_MECHS = ["DIGEST-MD5", "PLAIN", "LOGIN"] + +class Error(Exception): + pass + +class Response(Exception): + def __init__(self, code, data): + self.code = code + self.data = data + + def __str__(self): + return "%s %s" % (self.code, self.data) + +def authentication_required(meth): + """Simple class method decorator. + + Checks if the client is currently connected. + + :param meth: the original called method + """ + def check(cls, *args, **kwargs): + if cls.authenticated: + return meth(cls, *args, **kwargs) + raise Error("Authentication required") + return check + +class ManageSieveClient: + read_size = 4096 + read_timeout = 5 + + def __init__(self, srvaddr, srvport=2000, debug=False): + self.srvaddr = srvaddr + self.srvport = srvport + self.__debug = debug + self.sock = None + self.__read_buffer = "" + self.authenticated = False + self.errcode = None + + self.__capabilities = {} + self.__respcode_expr = re.compile("(OK|NO|BYE)\s*(.+)?") + self.__error_expr = re.compile('(\(\w+\))?\s*(".+")') + self.__size_expr = re.compile("\{(\d+)\+?\}") + self.__active_expr = re.compile("ACTIVE", re.IGNORECASE) + + def __del__(self): + if self.sock is not None: + self.sock.close() + self.sock = None + + def __dprint(self, message): + if not self.__debug: + return + print "DEBUG: %s" % message + + def __read_block(self, size): + """Read a block of 'size' bytes from the server. + + An internal buffer is used to read data from the server. If + enough data is available from it, we return that data. + + Eventually, we try to grab the missing part from the server + for ManageSieveClient.read_timeout seconds. If no data can be + retrieved, it is considered as a fatal error and an 'Error' + exception is raised. + + :param size: number of bytes to read + :rtype: string + :returns: the read block (can be empty) + """ + buf = "" + if len(self.__read_buffer): + limit = size if size <= len(self.__read_buffer) else len(self.__read_buffer) + buf = self.__read_buffer[:limit] + self.__read_buffer = self.__read_buffer[limit:] + size -= limit + if not size: + return buf + try: + buf += self.sock.recv(size) + except (socket.timeout, ssl.SSLError), e: + raise Error("Failed to read %d bytes from the server" % size) + return buf + + def __read_line(self): + """Read one line from the server. + + An internal buffer is used to read data from the server + (blocks of ManageSieveClient.read_size bytes). If the buffer + is not empty, we try to find an entire line to return. + + If we failed, we try to read new content from the server for + ManageSieveClient.read_timeout seconds. If no data can be + retrieved, it is considered as a fatal error and an 'Error' + exception is raised. + + :rtype: string + :return: the read line + """ + ret = "" + while True: + try: + pos = self.__read_buffer.index(CRLF) + ret = self.__read_buffer[0:pos] + self.__read_buffer = self.__read_buffer[pos+len(CRLF):] + break + except ValueError: + pass + try: + nval = self.sock.recv(self.read_size) + if not len(nval): + break + self.__read_buffer += nval + except (socket.timeout, ssl.SSLError): + raise Error("Failed to read data from the server") + if len(ret): + m = self.__respcode_expr.match(ret) + if m: + if m.group(1) == "BYE": + raise Error("Connection closed by server") + if m.group(1) == "NO": + self.__parse_error(m.group(2)) + raise Response(m.group(1), m.group(2)) + return ret + + def __read_response(self, nblines=-1): + """Read a response from the server. + + In the usual case, we read lines until we find one that looks + like a response (OK|NO|BYE\s*(.+)?). + + If *nblines* > 0, we read excactly nblines before returning. + + :param nblines: number of lines to read (default : -1) + :rtype: tuple + :return: a tuple of the form (code, data, response). If + nblines is provided, code and data can be equal to None. + """ + resp, code, data = ("", None, None) + cpt = 0 + while True: + try: + line = self.__read_line() + except Response, inst: + code = inst.code + data = inst.data + break + if not len(line): + break + resp += line + CRLF + cpt += 1 + if nblines != -1 and cpt == nblines: + break + + return (code, data, resp) + + def __prepare_args(self, args): + """Format command arguments before sending them. + + Command arguments of type string must be quoted, the only + exception concerns size indication (of the form {\d\+?}). + + :param args: list of arguments + :return: a list for transformed arguments + """ + ret = [] + for a in args: + if type(a) in [str, unicode] and self.__size_expr.match(a) is None: + ret += ['"%s"' % a] + continue + ret += ["%s" % str(a)] + return ret + + def __send_command(self, name, args=[], withcontent=False, extralines=[], nblines=-1): + """Send a command to the server. + + If args is not empty, we concatenate the given command with + the content of this list. If extralines is not empty, they are + sent one by one to the server. (CLRF are automatically appended to them) + + We wait for a response just after the command has been sent. + + :param name: the command to sent + :param args: a list of arguments for this command + :param withcontent: tells the function to return the server's response or not + :param extralines: a list of extra lines to sent after the command + :param nblines: the number of response lines to read (all by default) + + :returns: a tuple of the form (code, data[, response]) + """ + tosend = name + if len(args): + tosend += " " + " ".join(self.__prepare_args(args)) + self.__dprint("Command: %s" % tosend) + self.sock.send("%s%s" % (tosend, CRLF)) + for l in extralines: + self.sock.send("%s%s" % (l, CRLF)) + code, data, content = self.__read_response(nblines) + + if withcontent: + return (code, data, content) + return (code, data) + + def __get_capabilities(self): + code, data, capabilities = self.__read_response() + if code == "NO": + return False + + for l in capabilities.splitlines()[0:-2]: + parts = l.split(None, 1) + cname = parts[0].strip('"') + if not cname in KNOWN_CAPABILITIES: + continue + self.__capabilities[cname] = \ + parts[1].strip('"') if len(parts) > 1 else None + return True + + def __parse_error(self, text): + """Parse an error received from the server. + + if text corresponds to a size indication, we grab the + remaining content from the server. + + Otherwise, we try to match an error of the form \(\w+\)?\s*".+" + + On succes, the two public members errcode and errmsg are + filled with the parsing results. + + :param text: the response to parse + """ + m = self.__size_expr.match(text) + if m is not None: + self.errcode = "" + self.errmsg = self.__read_block(int(m.group(1)) + 2) + return + + m = self.__error_expr.match(text) + if m is None: + raise Error("Bad error message") + if m.group(1) is not None: + self.errcode = m.group(1).strip("()") + else: + self.errcode = "" + self.errmsg = m.group(2).strip('"') + + def _plain_authentication(self, login, password): + """SASL PLAIN authentication + + :param login: username + :param password: clear password + :return: True on success, False otherwise. + """ + params = base64.b64encode('\0' + '\0'.join([login, password])) + code, data = self.__send_command("AUTHENTICATE", ["PLAIN", params]) + if code == "OK": + return True + return False + + def _login_authentication(self, login, password): + """SASL LOGIN authentication + + :param login: username + :param password: clear password + :return: True on success, False otherwise. + """ + extralines = ['"%s"' % base64.b64encode(login), + '"%s"' % base64.b64encode(password)] + code, data = self.__send_command("AUTHENTICATE", ["LOGIN"], + extralines=extralines) + if code == "OK": + return True + return False + + def _digest_md5_authentication(self, login, password): + """SASL DIGEST-MD5 authentication + + :param login: username + :param password: clear password + :return: True on success, False otherwise. + """ + code, data, challenge = \ + self.__send_command("AUTHENTICATE", ["DIGEST-MD5"], + withcontent=True, nblines=1) + dmd5 = DigestMD5(challenge, "sieve/%s" % self.srvaddr) + + code, data, challenge = \ + self.__send_command('"%s"' % dmd5.response(login, password), + withcontent=True, nblines=1) + if not len(challenge): + return False + if not dmd5.check_last_challenge(login, password, challenge): + self.errmsg = "Bad challenge received from server" + return False + code, data = self.__send_command('""') + if code == "OK": + return True + return False + + def __authenticate(self, login, password, authmech=None): + """AUTHENTICATE command + + Actually, it is just a wrapper to the real commands (one by + mechanism). We try all supported mechanisms (from the + strongest to the weakest) until we find one supported by the + server. + + Then we try to authenticate (only once). + + :param login: username + :param password: clear password + :param authmech: prefered authentication mechanism + :return: True on success, False otherwise + """ + if not self.__capabilities.has_key("SASL"): + raise Error("SASL not supported by the server") + srv_mechanisms = self.get_sasl_mechanisms() + + if authmech is None or authmech not in SUPPORTED_AUTH_MECHS: + mech_list = SUPPORTED_AUTH_MECHS + else: + mech_list = [authmech] + + for mech in mech_list: + if not mech in srv_mechanisms: + continue + mech = mech.lower() + mech = re.sub("-", "_", mech) + if getattr(self, "_%s_authentication" % mech.lower())(login, password): + self.authenticated = True + return True + return False + + self.errmsg = "No suitable mechanism found" + return False + + def __starttls(self, keyfile=None, certfile=None): + """STARTTLS command + + See MANAGESIEVE specifications, section 2.2. + + :param keyfile: an eventual private key to use + :param certfile: an eventual certificate to use + :rtype: boolean + """ + if not self.has_tls_support(): + raise Error("STARTTLS not supported by the server") + code, data = self.__send_command("STARTTLS") + if code != "OK": + return False + try: + nsock = ssl.wrap_socket(self.sock, keyfile, certfile) + except ssl.SSLError, e: + raise Error("SSL error: %s" % str(e)) + self.sock = nsock + self.__capabilities = {} + self.__get_capabilities() + return True + + def get_implementation(self): + """Returns the IMPLEMENTATION value. + + It is read from server capabilities. (see the CAPABILITY + command) + + :rtype: string + """ + return self.__capabilities["IMPLEMENTATION"] + + def get_sasl_mechanisms(self): + """Returns the supported authentication mechanisms. + + They're read from server capabilities. (see the CAPABILITY + command) + + :rtype: list of string + """ + return self.__capabilities["SASL"].split() + + def has_tls_support(self): + """Tells if the server has STARTTLS support or not. + + It is read from server capabilities. (see the CAPABILITY + command) + + :rtype: boolean + """ + return self.__capabilities.has_key("STARTTLS") + + def get_sieve_capabilities(self): + """Returns the SIEVE extensions supported by the server. + + They're read from server capabilities. (see the CAPABILITY + command) + + :rtype: string + """ + if type(self.__capabilities["SIEVE"]) == str: + self.__capabilities["SIEVE"] = self.__capabilities["SIEVE"].split() + return self.__capabilities["SIEVE"] + + def connect(self, login, password, starttls=False, authmech=None): + """Establish a connection with the server. + + This function must be used. It read the server capabilities + and wraps calls to STARTTLS and AUTHENTICATE commands. + + :param login: username + :param password: clear password + :param starttls: use a TLS connection or not + :param authmech: prefered authenticate mechanism + :rtype: boolean + """ + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.srvaddr, self.srvport)) + self.sock.settimeout(ManageSieveClient.read_timeout) + except socket.error, msg: + raise Error("Connection to server failed: %s" % str(msg)) + + if not self.__get_capabilities(): + raise Error("Failed to read capabilities from server") + if starttls and not self.__starttls(): + return False + if self.__authenticate(login, password, authmech): + return True + return False + + def logout(self): + """Disconnect from the server + + See MANAGESIEVE specifications, section 2.3 + """ + self.__send_command("LOGOUT") + + def capability(self): + """Ask server capabilities. + + See MANAGESIEVE specifications, section 2.4 This command does + not affect capabilities recorded by this client. + + :rtype: string + """ + code, data, capabilities = \ + self.__send_command("CAPABILITY", withcontent=True) + if code == "OK": + return capabilities + return None + + @authentication_required + def havespace(self, scriptname, scriptsize): + """Ask for available space. + + See MANAGESIEVE specifications, section 2.5 + + :param scriptname: script's name + :param scriptsize: script's size + :rtype: boolean + """ + code, data = self.__send_command("HAVESPACE", [scriptname, scriptsize]) + if code == "OK": + return True + return False + + @authentication_required + def listscripts(self): + """List available scripts. + + See MANAGESIEVE specifications, section 2.7 + + :returns: a 2-uple (active script, [script1, ...]) + """ + code, data, listing = self.__send_command("LISTSCRIPTS", withcontent=True) + if code == "NO": + return None + ret = [] + active_script = None + for l in listing.splitlines(): + if self.__size_expr.match(l): + continue + m = re.match('"([^"]+)"\s*(.+)', l) + if m is None: + ret += [l.strip('"')] + else: + if self.__active_expr.match(m.group(2)): + active_script = m.group(1) + else: + ret += [m.group(1)] + self.__dprint(ret) + return (active_script, ret) + + @authentication_required + def getscript(self, name): + """Download a script from the server + + See MANAGESIEVE specifications, section 2.9 + + :param name: script's name + :rtype: string + :returns: the script's content on succes, None otherwise + """ + code, data, content = self.__send_command("GETSCRIPT", [name], withcontent=True) + if code == "OK": + lines = content.splitlines() + if self.__size_expr.match(lines[0]) is not None: + lines = lines[1:] + return "\n".join(lines) + return None + + @authentication_required + def putscript(self, name, content): + """Upload a script to the server + + See MANAGESIEVE specifications, section 2.6 + + :param name: script's name + :param content: script's content + :rtype: boolean + """ + content = "{%d+}%s%s" % (len(content), CRLF, content) + code, data = \ + self.__send_command("PUTSCRIPT", [name, content]) + if code == "OK": + return True + return False + + @authentication_required + def deletescript(self, name): + """Delete a script from the server + + See MANAGESIEVE specifications, section 2.10 + + :param name: script's name + :rtype: boolean + """ + code, data = self.__send_command("DELETESCRIPT", [name]) + if code == "OK": + return True + return False + + @authentication_required + def renamescript(self, oldname, newname): + """Rename a script on the server + + See MANAGESIEVE specifications, section 2.11.1 + + As this command is optional, we emulate it if the server does + not support it. + + :param oldname: current script's name + :param newname: new script's name + :rtype: boolean + """ + if self.__capabilities.has_key("RENAMESCRIPT"): + code, data = self.__send_command("RENAMESCRIPT", [oldname, newname]) + if code == "OK": + return True + return False + + (active_script, scripts) = self.listscripts() + if scripts == None or not oldname in scripts: + self.errmsg = "Old script does not exist" + return False + if newname in scripts: + self.errmsg = "New script already exists" + return False + oldscript = self.getscript(oldname) + if oldscript is None: + return False + if not self.putscript(newname, oldscript): + return False + if active_script == oldname: + if not self.setactive(newname): + return False + if not self.deletescript(oldname): + return False + return True + + @authentication_required + def setactive(self, scriptname): + """Define the active script + + See MANAGESIEVE specifications, section 2.8 + + If scriptname is empty, the current active script is disabled, + ie. there will be no active script anymore. + + :param scriptname: script's name + :rtype: boolean + """ + code, data = self.__send_command("SETACTIVE", [scriptname]) + if code == "OK": + return True + return False + diff --git a/extensions/sievefilters/static/css/style.css b/extensions/sievefilters/static/css/style.css new file mode 100644 index 000000000..6d516fb32 --- /dev/null +++ b/extensions/sievefilters/static/css/style.css @@ -0,0 +1,72 @@ +#sets_list { + position: absolute; + border: 1px solid #CCC; + left: 10px; + top: 0px; + bottom: 10px; + width: 160px; + +} + +#set_content { + position: absolute; + left: 180px; + right: 10px; + top: 0px; + bottom: 10px; + padding: 0; +} + +#sets_list ul { + list-style: none; + padding: 5px 0 0 5px; + margin: 0; +} + +#sets_list li { + margin: 2px 0; +} + +#sets_list a, #sets_list a:visited { + color: black; + text-decoration: none; +} + +#sets_list a:hover { + color: #6AB0C5; + text-decoration: none; +} + +#sets_list li.selected { + background-color: #FFFF77; +} + +#sets_list img { + float: right; +} + +#set_content form { + height: 100%; +} + +#set_content textarea { + margin: 0; + width: 100%; + height: 100%; +} + +#id_content { + width: 95% !important; + height: 270px; +} + +#submit_container { + width: 100%; + position: absolute; + bottom: 5px; + text-align: center; +} + +#error { + white-space: pre-line; +} \ No newline at end of file diff --git a/extensions/sievefilters/templates/sievefilters/index.html b/extensions/sievefilters/templates/sievefilters/index.html new file mode 100644 index 000000000..c20f5eda6 --- /dev/null +++ b/extensions/sievefilters/templates/sievefilters/index.html @@ -0,0 +1,121 @@ +{% extends "nlayout.html" %} +{% load i18n libextras sfilters_tags %} +{% block html_head %} + + +{% endblock %} +{% block middletitle %}{% trans "Message filters" %}{% endblock %} +{% block menu %}{% sfilters_menu user %}{% endblock %} +{% block content %} +{% if messages %} + {% display_messages messages %} +{% endif %} +
+ +
+
+
+
+{% endblock %} diff --git a/extensions/sievefilters/templates/sievefilters/newfiltersset.html b/extensions/sievefilters/templates/sievefilters/newfiltersset.html new file mode 100644 index 000000000..b7f2acef5 --- /dev/null +++ b/extensions/sievefilters/templates/sievefilters/newfiltersset.html @@ -0,0 +1,16 @@ +{% extends "layout_simple.html" %} +{% load i18n %} +{% block html_head %} + + +{% endblock %} +{% block content %} +

{{ title }}

+{% if error %}
{{ error }}
{% endif %} +
+ {% include "common/genericform.html" %} +
+ +
+
+{% endblock %} diff --git a/extensions/sievefilters/templates/sievefilters/rawfilter.html b/extensions/sievefilters/templates/sievefilters/rawfilter.html new file mode 100644 index 000000000..96e1c42a8 --- /dev/null +++ b/extensions/sievefilters/templates/sievefilters/rawfilter.html @@ -0,0 +1,6 @@ +
+ + +
+ diff --git a/extensions/sievefilters/templatetags/__init__.py b/extensions/sievefilters/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/sievefilters/templatetags/sfilters_tags.py b/extensions/sievefilters/templatetags/sfilters_tags.py new file mode 100644 index 000000000..ecfeee1bf --- /dev/null +++ b/extensions/sievefilters/templatetags/sfilters_tags.py @@ -0,0 +1,36 @@ +from django import template +from django.template.loader import render_to_string +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +from modoboa.extensions.sievefilters import views + +register = template.Library() + +from modoboa.lib import static_url + +@register.simple_tag +def sfilters_menu(user): + entries = [ + {"name" : "newfilterset", + "img" : static_url("pics/add.png"), + "label" : _("New filters set"), + "url" : reverse(views.new_filters_set), + "class" : "boxed", + "rel" : "{handler:'iframe',size:{x:300,y:170}}" + }, + {"name" : "savescript", + "img" : static_url("pics/save.png"), + "label" : _("Save filters set"), + "url" : ""}, + {"name" : "activatescript", + "img" : static_url("pics/active.png"), + "label" : _("Activate filters set"), + "url" : reverse(views.activate_filters_set)}, + {"name" : "deletescript", + "img" : static_url("pics/remove.png"), + "label" : _("Remove filters set"), + "url" : reverse(views.delete_filters_set),} + ] + + return render_to_string('common/menulist.html', + {"entries" : entries, "user" : user}) diff --git a/extensions/sievefilters/test.py b/extensions/sievefilters/test.py new file mode 100644 index 000000000..2555f22bc --- /dev/null +++ b/extensions/sievefilters/test.py @@ -0,0 +1,43 @@ +if __name__ == "__main__": + clt = ManageSieveClient("mail.koalabs.org", 2000) + if clt.connect("tonio@ngyn.org", "aka/12;c", starttls=True): + print clt.get_sasl_mechanisms() + print clt.get_sieve_capabilities() + print clt.havespace("test", 45) + + if not clt.putscript("myscript", """require ["fileinto", "envelope"]; + +if envelope :contains "to" "tmartin+sent" { + fileinto "INBOX.sent"; +} +"""): + print clt.errmsg + + if not clt.putscript("test", """#comment +InvalidSieveCommand +"""): + print clt.errmsg + + clt.deletescript("thescript") + + if not clt.setactive("myscript"): + print clt.errmsg + print clt.listscripts() + + sc = clt.getscript("myscript") + if sc is None: + print clt.errmsg + else: + print sc + + if not clt.renamescript("myscript", "thescript"): + print clt.errmsg + + clt.setactive("") + + if not clt.deletescript("thescript"): + print clt.errmsg + else: + print clt.errmsg + clt.logout() + diff --git a/extensions/sievefilters/urls.py b/extensions/sievefilters/urls.py new file mode 100644 index 000000000..6c1e8c40e --- /dev/null +++ b/extensions/sievefilters/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns('modoboa.extensions.sievefilters.views', + (r'^$', 'index'), + (r'^getscript/$', 'getscript'), + (r'^savescript/$', 'savescript'), + (r'^newfs/$', 'new_filters_set'), + (r'^deletefs/$', 'delete_filters_set'), + (r'^activatefs/$', 'activate_filters_set'), + ) diff --git a/extensions/sievefilters/views.py b/extensions/sievefilters/views.py new file mode 100644 index 000000000..f5e9e3e7f --- /dev/null +++ b/extensions/sievefilters/views.py @@ -0,0 +1,103 @@ +# coding: utf-8 + +from django.http import HttpResponse +from django.contrib.auth.decorators import login_required +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +from modoboa.lib import _render, ajax_response +from modoboa.auth.lib import get_password +from lib import * +from forms import * + +@login_required +def index(request, tplname="sievefilters/index.html"): + sc = SieveClient(user=request.user.username, + password=request.session["password"]) + active_script, scripts = sc.listscripts() + return _render(request, tplname, { + "active_script" : active_script, + "scripts" : sorted(scripts) + }) + +@login_required +def getscript(request): + if not request.GET.has_key("name") or request.GET["name"] == "": + return + sc = SieveClient(user=request.user.username, + password=request.session["password"]) + try: + content = sc.getscript(request.GET["name"]) + except SieveClientError, e: + pass + return ajax_response(request, template="sievefilters/rawfilter.html", + name=request.GET["name"], scriptcontent=content) + +@login_required +def savescript(request): + if not request.POST.has_key("scriptname") or \ + not request.POST.has_key("scriptcontent"): + return + sc = SieveClient(user=request.user.username, + password=request.session["password"]) + try: + sc.pushscript(request.POST["scriptname"], + request.POST["scriptcontent"]) + except SieveClientError, e: + error = str(e) + return ajax_response(request, "ko", respmsg=error) + + return ajax_response(request, respmsg=_("Filters set saved")) + +@login_required +def new_filters_set(request, tplname="sievefilters/newfiltersset.html"): + ctx = {"title" : _("Create a new filters set"), + "fname" : "newfiltersset", + "submit_label" : _("Create"), + "withmenu" : False, + "withunseen" : False} + if request.method == "POST": + form = FiltersSetForm(request.POST) + error = None + if form.is_valid(): + sc = SieveClient(user=request.user.username, + password=request.session["password"]) + try: + sc.pushscript(form.cleaned_data["name"], "# Empty script", + form.cleaned_data["active"]) + except SieveClientError, e: + error = str(e) + else: + return ajax_response(request, url=reverse(index), + respmsg=_("Filters set created")) + ctx["form"] = form + ctx["error"] = error + return ajax_response(request, status="ko", template=tplname, **ctx) + + ctx["form"] = FiltersSetForm() + return _render(request, tplname, ctx) + +@login_required +def delete_filters_set(request): + if not request.GET.has_key("name") or \ + request.GET["name"] == "": + return + sc = SieveClient(user=request.user.username, + password=request.session["password"]) + try: + sc.deletescript(request.GET["name"]) + except SieveClientError, e: + return ajax_response(request, "ko", respmsg=str(e)) + return ajax_response(request, respmsg=_("Filters set deleted")) + +@login_required +def activate_filters_set(request): + if not request.GET.has_key("name") or \ + request.GET["name"] == "": + return + sc = SieveClient(user=request.user.username, + password=request.session["password"]) + try: + sc.activatescript(request.GET["name"]) + except SieveClientError, e: + return ajax_response(request, "ko", respmsg=str(e)) + return ajax_response(request, respmsg=_("Filters set activated")) diff --git a/extensions/webmail/lib.py b/extensions/webmail/lib.py index d14729b65..f0893d0fe 100644 --- a/extensions/webmail/lib.py +++ b/extensions/webmail/lib.py @@ -14,9 +14,9 @@ import email.utils import lxml from django.utils.translation import ugettext as _ from django.conf import settings -from modoboa.lib import decode, tables, imap_utf7, Singleton +from modoboa.lib import decode, u2u_decode, tables, \ + imap_utf7, static_url, ConnectionsManager, parameters from modoboa.lib.email_listing import MBconnector, EmailListing, Email -from modoboa.lib import tables, imap_utf7, parameters, static_url, u2u_decode class WebmailError(Exception): errorexpr = re.compile("\[([^\]]+)\]\s*([^\.]+)") @@ -113,29 +113,6 @@ class IMAPheader(object): def parse_subject(value, **kwargs): return decode(u2u_decode.u2u_decode(value)) -class ConnectionsManager(type): - def __init__(cls, name, bases, dict): - super(ConnectionsManager, cls).__init__(name, bases, dict) - cls.instances = {} - - def __call__(cls, **kwargs): - key = None - if kwargs.has_key("user"): - key = kwargs["user"] - else: - return None - if not cls.instances.has_key(key): - cls.instances[key] = None - if kwargs.has_key("password"): - kwargs["password"] = decrypt(kwargs["password"]) - - if cls.instances[key] is None: - cls.instances[key] = \ - super(ConnectionsManager, cls).__call__(**kwargs) - else: - cls.instances[key].refresh(key, kwargs["password"]) - return cls.instances[key] - class IMAPconnector(object): __metaclass__ = ConnectionsManager @@ -248,6 +225,10 @@ class IMAPconnector(object): for mb in data: flags, delimiter, name = list_response_pattern.match(mb).groups() name = name.strip('"').decode("imap4-utf-7") + # Dovecot stores user scripts into a specific folder, I'm + # wondering why this folder appears in 'list' result? + if name in ["dovecot.sieve"]: + continue if re.search("\%s" % delimiter, name): parts = name.split(".") if not descr.has_key("path"): @@ -642,24 +623,3 @@ def find_images_in_body(body): return lxml.html.tostring(html), parts - -def encrypt(clear): - from Crypto.Cipher import AES - import base64 - - obj = AES.new(parameters.get_admin("SECRET_KEY"), - AES.MODE_ECB) - if len(clear) % AES.block_size: - clear += "\0" * (AES.block_size - len(clear) % AES.block_size) - ciph = obj.encrypt(clear) - ciph = base64.b64encode(ciph) - return ciph - -def decrypt(ciph): - from Crypto.Cipher import AES - import base64 - - obj = AES.new(parameters.get_admin("SECRET_KEY"), AES.MODE_ECB) - ciph = base64.b64decode(ciph) - clear = obj.decrypt(ciph) - return clear.rstrip('\0') diff --git a/extensions/webmail/main.py b/extensions/webmail/main.py index fd20cfe77..88d6adf1c 100644 --- a/extensions/webmail/main.py +++ b/extensions/webmail/main.py @@ -6,11 +6,8 @@ from modoboa.lib import events, parameters, static_url def init(): events.register("UserMenuDisplay", menu) - events.register("UserLogin", userlogin) events.register("UserLogout", userlogout) - parameters.register_admin("SECRET_KEY", type="string", deflt="abcdefghijklmnop", - help=_("Key used to encrypt/decrypt passwords")) parameters.register_admin("IMAP_SERVER", type="string", deflt="127.0.0.1", help=_("Address of your IMAP server")) @@ -67,7 +64,6 @@ def init(): def destroy(): events.unregister("UserMenuDisplay", menu) - events.unregister("UserLogin", userlogin) parameters.unregister_app("webmail") def infos(): @@ -93,13 +89,6 @@ def menu(**kwargs): "img" : static_url("pics/webmail.png")} ] -def userlogin(**kwargs): - from lib import IMAPconnector, encrypt - - if kwargs["request"].user.id == 1: - return - kwargs["request"].session["password"] = encrypt(kwargs["password"]) - def userlogout(**kwargs): from lib import IMAPconnector diff --git a/extensions/webmail/views.py b/extensions/webmail/views.py index aabf3a526..cd68f9085 100644 --- a/extensions/webmail/views.py +++ b/extensions/webmail/views.py @@ -14,6 +14,7 @@ from modoboa.lib import parameters, _render, _render_error, \ getctx, is_not_localadmin, _render_to_string, split_mailbox, \ ajax_response from modoboa.lib.email_listing import parse_search_parameters, Paginator +from modoboa.auth.lib import * from lib import * from forms import * from templatetags.webextras import * @@ -365,7 +366,7 @@ def send_mail(request, withctx=False, origmsg=None, posturl=None): error = str(text) if error is None: if parameters.get_admin("SMTP_AUTHENTICATION") == "yes": - s.login(request.user.username, decrypt(request.session["password"])) + s.login(request.user.username, get_password(request)) s.sendmail(msg['From'], rcpts, msg.as_string()) s.quit() sentfolder = parameters.get_user(request.user, "SENT_FOLDER") diff --git a/lib/__init__.py b/lib/__init__.py index 1a3710c55..9e58335ae 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -12,6 +12,7 @@ from django.template.loader import render_to_string from django.utils.translation import ugettext as _ from django.utils import simplejson from modoboa.lib import parameters +from modoboa.auth.lib import decrypt def exec_cmd(cmd, **kwargs): import subprocess @@ -94,6 +95,8 @@ def ajax_response(request, status="ok", respmsg=None, ctx[k] = v if template is not None: content = _render_to_string(request, template, ctx) + elif kwargs.has_key("content"): + content = kwargs["content"] else: content = "" jsonctx = {"status" : status, "content" : content} @@ -156,17 +159,6 @@ def db_table_exists(table, cursor=None): else: return table in table_names -class Singleton(type): - def __init__(cls, name, bases, dict): - super(Singleton, cls).__init__(name, bases, dict) - cls.instance = None - - def __call__(cls, *args, **kwargs): - if cls.instance is None: - cls.instance = super(Singleton, cls).__call__(*args, **kwargs) - return cls.instance - - def _check_password(password, crypted): scheme = parameters.get_admin("PASSWORD_SCHEME", app="admin") if scheme == "crypt": @@ -193,3 +185,33 @@ def split_mailbox(mailbox): domain = parts[-1] address = "@".join(parts[:-1]) return (address, domain) + +class ConnectionsManager(type): + """Singleton pattern implementation + + This class is specialized in connection management. + """ + def __init__(cls, name, bases, dict): + super(ConnectionsManager, cls).__init__(name, bases, dict) + cls.instances = {} + + def __call__(cls, **kwargs): + key = None + if kwargs.has_key("user"): + key = kwargs["user"] + else: + return None + if not cls.instances.has_key(key): + cls.instances[key] = None + if kwargs.has_key("password"): + kwargs["password"] = decrypt(kwargs["password"]) + + if cls.instances[key] is None: + cls.instances[key] = \ + super(ConnectionsManager, cls).__call__(**kwargs) + else: + cls.instances[key].refresh(key, kwargs["password"]) + return cls.instances[key] + +class ConnectionError(Exception): + pass diff --git a/lib/migrations/0002_move_secret_key.py b/lib/migrations/0002_move_secret_key.py new file mode 100644 index 000000000..d6151d6a7 --- /dev/null +++ b/lib/migrations/0002_move_secret_key.py @@ -0,0 +1,73 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models + +class Migration(DataMigration): + + def forwards(self, orm): + "Write your forwards methods here." + p = orm.Parameter.objects.get(name="webmail.SECRET_KEY") + p.name = "auth.SECRET_KEY" + p.save() + + def backwards(self, orm): + "Write your backwards methods here." + p = orm.Parameter.objects.get(name="auth.SECRET_KEY") + p.name = "webmail.SECRET_KEY" + p.save() + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'lib.parameter': { + 'Meta': {'object_name': 'Parameter'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'value': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'lib.userparameter': { + 'Meta': {'object_name': 'UserParameter'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + } + } + + complete_apps = ['lib'] diff --git a/settings_sample.py b/settings_sample.py index f7b55d9e7..9ce8917c9 100644 --- a/settings_sample.py +++ b/settings_sample.py @@ -102,6 +102,7 @@ INSTALLED_APPS = ( 'modoboa.extensions.webmail', 'modoboa.extensions.stats', 'modoboa.extensions.amavis_quarantine', + 'modoboa.extensions.sievefilters', ) AUTHENTICATION_BACKENDS = ( diff --git a/static/css/box.css b/static/css/box.css index 1e62bcabd..11eb64575 100644 --- a/static/css/box.css +++ b/static/css/box.css @@ -6,7 +6,6 @@ body { } div.row { - /*position: relative;*/ clear: both; padding-top: 10px; padding-left: 20px; @@ -134,3 +133,7 @@ td.even { background: #eee; color: black; } + +.spacer { + clear: both; +} \ No newline at end of file diff --git a/static/js/form.js b/static/js/form.js index 60564b2b4..9afb11770 100644 --- a/static/js/form.js +++ b/static/js/form.js @@ -13,6 +13,10 @@ var error_callbacks = { } }; +var success_callbacks = { + +}; + var extra_data_callbacks = { }; @@ -29,6 +33,10 @@ function register_error_callback(name, callback) { return register_callback(error_callbacks, name, callback); } +function register_success_callback(name, callback) { + return register_callback(success_callbacks, name, callback); +} + function register_extra_data_callback(name, callback) { return register_callback(extra_data_callbacks, name, callback); } @@ -80,7 +88,10 @@ function ajax_submit(event) { } else if ($defined(response.url)) { parent.location.href = response.url; } - parent.SqueezeBox.close(); + if ($defined(success_callbacks[myid])) { + success_callbacks[myid](response); + } + parent.SqueezeBox.close(); } }, onFailure: function(xhr) { diff --git a/static/js/infobox.js b/static/js/infobox.js index f435fe3da..b9a47c80c 100644 --- a/static/js/infobox.js +++ b/static/js/infobox.js @@ -22,13 +22,11 @@ var InfoBox = new Class({ "font-size" : "1em", "font-weight" : "bold", "text-align" : "left", - "width" : this.options.width + "px", "padding" : "10px", "position" : "absolute", - "top" : "0px", - "left" : boxleft + "px", "display" : "none" }); + this.calcsize(); this.setProfile("gray"); this.spinner = new Element("img", { "src" : "/static/css/spinner.gif" @@ -38,12 +36,24 @@ var InfoBox = new Class({ this.label = new Element("label").set("html", this.options.message); this.label.setStyles({ - "margin-left" : "10px" + "white-space": "pre-line" + /*"margin-left" : "10px"*/ }); this.label.inject(this.box); this.box.inject($(document.body)); }, + calcsize: function(width) { + var w = ($defined(width) ? width : this.options.width); + var boxleft = $(document.body).getSize().x / 2 - w / 2; + + this.box.setStyles({ + width: w + "px", + left: boxleft + "px", + top: "0px" + }); + }, + setMessage: function(text) { this.label.set("html", text); }, diff --git a/static/pics/active.png b/static/pics/active.png new file mode 100644 index 000000000..7dc15ea96 Binary files /dev/null and b/static/pics/active.png differ diff --git a/static/pics/filters.png b/static/pics/filters.png new file mode 100644 index 000000000..6edbef614 Binary files /dev/null and b/static/pics/filters.png differ diff --git a/static/pics/save.png b/static/pics/save.png new file mode 100644 index 000000000..22ff49571 Binary files /dev/null and b/static/pics/save.png differ