# HG changeset patch # User Goffi # Date 1458683165 -3600 # Node ID b111f6589da48f126e8784bc6d50459a6626b3a3 # Parent 03526c8abeb0d155b88f61f33db5631ddf9fa024 jp (blog): drafts are now put in a sub dir per profile + added a security which keep the last 10 drafts before removing them (all profiles included). diff -r 03526c8abeb0 -r b111f6589da4 frontends/src/jp/cmd_blog.py --- a/frontends/src/jp/cmd_blog.py Tue Mar 22 22:46:04 2016 +0100 +++ b/frontends/src/jp/cmd_blog.py Tue Mar 22 22:46:05 2016 +0100 @@ -33,6 +33,7 @@ import shlex import glob from sat.tools.common import data_format +from sat.tools.common import regex __commands__ = ["Blog"] @@ -62,29 +63,76 @@ URL_REDIRECT_PREFIX = 'url_redirect_' INOTIFY_INSTALL = '"pip install inotify"' +SECURE_UNLINK_MAX = 10 * 2 # we double value has there are 2 files per draft (content and metadata) +SECURE_UNLINK_DIR = ".backup" class BlogCommon(object): - def getTmpDir(self, sat_conf): + + def __init__(self, host): + self.host = host + + def getTmpDir(self, sat_conf, sub_dir=None): """Return directory used to store temporary files @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration + @param sub_dir(str): sub directory where data need to be put + profile can be used here, or special directory name + sub_dir will be escaped to be usable in path (use regex.pathUnescape to find + initial str) @return (str): path to the dir """ local_dir = config.getConfig(sat_conf, '', 'local_dir', Exception) - return os.path.join(local_dir, BLOG_TMP_DIR) + path = [local_dir, BLOG_TMP_DIR] + if sub_dir is not None: + path.append(regex.pathEscape(sub_dir)) + return os.path.join(*path) - def getCurrentFile(self, sat_conf): + def getCurrentFile(self, sat_conf, profile): + """Get most recently edited file + + @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration + @param profile(unicode): profile linked to the blog draft + @return(str): full path of current file + """ # we guess the blog item currently edited by choosing # the most recent file corresponding to temp file pattern # in tmp_dir, excluding metadata files - tmp_dir = self.getTmpDir(sat_conf) + tmp_dir = self.getTmpDir(sat_conf, profile.encode('utf-8')) available = [path for path in glob.glob(os.path.join(tmp_dir, 'blog_*')) if not path.endswith(METADATA_SUFF)] if not available: self.disp(u"Counldn't find any content draft in {path}".format(path=tmp_dir), error=True) self.host.quit(1) return max(available, key=lambda path: os.stat(path).st_mtime) + def secureUnlink(self, sat_conf, path): + """Unlink given path after keeping it for a while + + This method is used to prevent accidental deletion of a blog draft + If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX, + older file are deleted + @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration + @param path(str): file to unlink + """ + if not os.path.isfile(path): + raise OSError(u"path must link to a regular file") + backup_dir = self.getTmpDir(sat_conf, SECURE_UNLINK_DIR) + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + filename = os.path.basename(path) + backup_path = os.path.join(backup_dir, filename) + # we move file to backup dir + self.host.disp(u"Backuping file {src} to {dst}".format( + src=path.decode('utf-8'), dst=backup_path.decode('utf-8')), 1) + os.rename(path, backup_path) + # and if we exceeded the limit, we remove older file + backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)] + if len(backup_files) > SECURE_UNLINK_MAX: + backup_files.sort(key=lambda path: os.stat(path).st_mtime) + for path in backup_files[:len(backup_files) - SECURE_UNLINK_MAX]: + self.host.disp(u"Purging backup file {}".format(path.decode('utf-8')), 2) + os.unlink(path) + def guessSyntaxFromPath(self, sat_conf, path): """Return syntax guessed according to filename extension @@ -121,7 +169,8 @@ class Edit(base.CommandBase, BlogCommon): def __init__(self, host): - super(Edit, self).__init__(host, 'edit', use_verbose=True, help=_(u'edit an existing or new blog post')) + base.CommandBase.__init__(self, host, 'edit', use_verbose=True, help=_(u'edit an existing or new blog post')) + BlogCommon.__init__(self, self.host) def add_parser_options(self): self.parser.add_argument("item", type=base.unicode_decoder, nargs='?', default=u'new', help=_(u"URL of the item to edit, or keyword")) @@ -137,7 +186,7 @@ @param tmp_suff (str): suffix to use for the filename @return (tuple(file, str)): opened (w+b) file object and file path """ - tmp_dir = self.getTmpDir(sat_conf) + tmp_dir = self.getTmpDir(sat_conf, self.profile.encode('utf-8')) if not os.path.exists(tmp_dir): try: os.makedirs(tmp_dir) @@ -283,8 +332,8 @@ else: self.disp(u"Blog item published") - os.unlink(content_file_path) - os.unlink(meta_file_path) + self.secureUnlink(sat_conf, content_file_path) + self.secureUnlink(sat_conf, meta_file_path) def start(self): item_lower = self.args.item.lower() @@ -319,7 +368,7 @@ mb_data = None if item_lower == 'current': # use wants to continue current draft - content_file_path = self.getCurrentFile(sat_conf) + content_file_path = self.getCurrentFile(sat_conf, self.profile) self.disp(u'Continuing edition of current draft', 2) else: # for now we taxe the item as a file path @@ -334,7 +383,8 @@ class Preview(base.CommandBase, BlogCommon): def __init__(self, host): - super(Preview, self).__init__(host, 'preview', use_verbose=True, help=_(u'preview a blog content')) + base.CommandBase.__init__(self, host, 'preview', use_verbose=True, help=_(u'preview a blog content')) + BlogCommon.__init__(self, self.host) def add_parser_options(self): self.parser.add_argument("--inotify", type=str, choices=('auto', 'true', 'false'), default=u'auto', help=_(u"use inotify to handle preview")) @@ -421,7 +471,7 @@ # which file do we need to edit? if self.args.file == 'current': - self.content_file_path = self.getCurrentFile(sat_conf) + self.content_file_path = self.getCurrentFile(sat_conf, self.profile) else: self.content_file_path = os.path.abspath(self.args.file)