comparison frontends/src/jp/cmd_blog.py @ 1921:b111f6589da4

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).
author Goffi <goffi@goffi.org>
date Tue, 22 Mar 2016 22:46:05 +0100
parents d3354c80bd1f
children e28c6c4dffa5
comparison
equal deleted inserted replaced
1920:03526c8abeb0 1921:b111f6589da4
31 import tempfile 31 import tempfile
32 import subprocess 32 import subprocess
33 import shlex 33 import shlex
34 import glob 34 import glob
35 from sat.tools.common import data_format 35 from sat.tools.common import data_format
36 from sat.tools.common import regex
36 37
37 __commands__ = ["Blog"] 38 __commands__ = ["Blog"]
38 39
39 # extensions to use with known syntaxes 40 # extensions to use with known syntaxes
40 SYNTAX_EXT = { 41 SYNTAX_EXT = {
60 # key to remove from metadata tmp file if they exist 61 # key to remove from metadata tmp file if they exist
61 KEY_TO_REMOVE_METADATA = ('id','content', 'content_xhtml', 'comments_node', 'comments_service', 'updated') 62 KEY_TO_REMOVE_METADATA = ('id','content', 'content_xhtml', 'comments_node', 'comments_service', 'updated')
62 63
63 URL_REDIRECT_PREFIX = 'url_redirect_' 64 URL_REDIRECT_PREFIX = 'url_redirect_'
64 INOTIFY_INSTALL = '"pip install inotify"' 65 INOTIFY_INSTALL = '"pip install inotify"'
66 SECURE_UNLINK_MAX = 10 * 2 # we double value has there are 2 files per draft (content and metadata)
67 SECURE_UNLINK_DIR = ".backup"
65 68
66 69
67 class BlogCommon(object): 70 class BlogCommon(object):
68 def getTmpDir(self, sat_conf): 71
72 def __init__(self, host):
73 self.host = host
74
75 def getTmpDir(self, sat_conf, sub_dir=None):
69 """Return directory used to store temporary files 76 """Return directory used to store temporary files
70 77
71 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration 78 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
79 @param sub_dir(str): sub directory where data need to be put
80 profile can be used here, or special directory name
81 sub_dir will be escaped to be usable in path (use regex.pathUnescape to find
82 initial str)
72 @return (str): path to the dir 83 @return (str): path to the dir
73 """ 84 """
74 local_dir = config.getConfig(sat_conf, '', 'local_dir', Exception) 85 local_dir = config.getConfig(sat_conf, '', 'local_dir', Exception)
75 return os.path.join(local_dir, BLOG_TMP_DIR) 86 path = [local_dir, BLOG_TMP_DIR]
76 87 if sub_dir is not None:
77 def getCurrentFile(self, sat_conf): 88 path.append(regex.pathEscape(sub_dir))
89 return os.path.join(*path)
90
91 def getCurrentFile(self, sat_conf, profile):
92 """Get most recently edited file
93
94 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
95 @param profile(unicode): profile linked to the blog draft
96 @return(str): full path of current file
97 """
78 # we guess the blog item currently edited by choosing 98 # we guess the blog item currently edited by choosing
79 # the most recent file corresponding to temp file pattern 99 # the most recent file corresponding to temp file pattern
80 # in tmp_dir, excluding metadata files 100 # in tmp_dir, excluding metadata files
81 tmp_dir = self.getTmpDir(sat_conf) 101 tmp_dir = self.getTmpDir(sat_conf, profile.encode('utf-8'))
82 available = [path for path in glob.glob(os.path.join(tmp_dir, 'blog_*')) if not path.endswith(METADATA_SUFF)] 102 available = [path for path in glob.glob(os.path.join(tmp_dir, 'blog_*')) if not path.endswith(METADATA_SUFF)]
83 if not available: 103 if not available:
84 self.disp(u"Counldn't find any content draft in {path}".format(path=tmp_dir), error=True) 104 self.disp(u"Counldn't find any content draft in {path}".format(path=tmp_dir), error=True)
85 self.host.quit(1) 105 self.host.quit(1)
86 return max(available, key=lambda path: os.stat(path).st_mtime) 106 return max(available, key=lambda path: os.stat(path).st_mtime)
107
108 def secureUnlink(self, sat_conf, path):
109 """Unlink given path after keeping it for a while
110
111 This method is used to prevent accidental deletion of a blog draft
112 If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX,
113 older file are deleted
114 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
115 @param path(str): file to unlink
116 """
117 if not os.path.isfile(path):
118 raise OSError(u"path must link to a regular file")
119 backup_dir = self.getTmpDir(sat_conf, SECURE_UNLINK_DIR)
120 if not os.path.exists(backup_dir):
121 os.makedirs(backup_dir)
122 filename = os.path.basename(path)
123 backup_path = os.path.join(backup_dir, filename)
124 # we move file to backup dir
125 self.host.disp(u"Backuping file {src} to {dst}".format(
126 src=path.decode('utf-8'), dst=backup_path.decode('utf-8')), 1)
127 os.rename(path, backup_path)
128 # and if we exceeded the limit, we remove older file
129 backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)]
130 if len(backup_files) > SECURE_UNLINK_MAX:
131 backup_files.sort(key=lambda path: os.stat(path).st_mtime)
132 for path in backup_files[:len(backup_files) - SECURE_UNLINK_MAX]:
133 self.host.disp(u"Purging backup file {}".format(path.decode('utf-8')), 2)
134 os.unlink(path)
87 135
88 def guessSyntaxFromPath(self, sat_conf, path): 136 def guessSyntaxFromPath(self, sat_conf, path):
89 """Return syntax guessed according to filename extension 137 """Return syntax guessed according to filename extension
90 138
91 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration 139 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
119 167
120 168
121 class Edit(base.CommandBase, BlogCommon): 169 class Edit(base.CommandBase, BlogCommon):
122 170
123 def __init__(self, host): 171 def __init__(self, host):
124 super(Edit, self).__init__(host, 'edit', use_verbose=True, help=_(u'edit an existing or new blog post')) 172 base.CommandBase.__init__(self, host, 'edit', use_verbose=True, help=_(u'edit an existing or new blog post'))
173 BlogCommon.__init__(self, self.host)
125 174
126 def add_parser_options(self): 175 def add_parser_options(self):
127 self.parser.add_argument("item", type=base.unicode_decoder, nargs='?', default=u'new', help=_(u"URL of the item to edit, or keyword")) 176 self.parser.add_argument("item", type=base.unicode_decoder, nargs='?', default=u'new', help=_(u"URL of the item to edit, or keyword"))
128 self.parser.add_argument("-P", "--preview", action="store_true", help=_(u"launch a blog preview in parallel")) 177 self.parser.add_argument("-P", "--preview", action="store_true", help=_(u"launch a blog preview in parallel"))
129 self.parser.add_argument("-T", '--title', type=base.unicode_decoder, help=_(u"title of the item")) 178 self.parser.add_argument("-T", '--title', type=base.unicode_decoder, help=_(u"title of the item"))
135 184
136 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration 185 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
137 @param tmp_suff (str): suffix to use for the filename 186 @param tmp_suff (str): suffix to use for the filename
138 @return (tuple(file, str)): opened (w+b) file object and file path 187 @return (tuple(file, str)): opened (w+b) file object and file path
139 """ 188 """
140 tmp_dir = self.getTmpDir(sat_conf) 189 tmp_dir = self.getTmpDir(sat_conf, self.profile.encode('utf-8'))
141 if not os.path.exists(tmp_dir): 190 if not os.path.exists(tmp_dir):
142 try: 191 try:
143 os.makedirs(tmp_dir) 192 os.makedirs(tmp_dir)
144 except OSError as e: 193 except OSError as e:
145 self.disp(u"Can't create {path} directory: {reason}".format( 194 self.disp(u"Can't create {path} directory: {reason}".format(
281 content_path=content_file_path, meta_path=meta_file_path, reason=e), error=True) 330 content_path=content_file_path, meta_path=meta_file_path, reason=e), error=True)
282 self.host.quit(1) 331 self.host.quit(1)
283 else: 332 else:
284 self.disp(u"Blog item published") 333 self.disp(u"Blog item published")
285 334
286 os.unlink(content_file_path) 335 self.secureUnlink(sat_conf, content_file_path)
287 os.unlink(meta_file_path) 336 self.secureUnlink(sat_conf, meta_file_path)
288 337
289 def start(self): 338 def start(self):
290 item_lower = self.args.item.lower() 339 item_lower = self.args.item.lower()
291 sat_conf = config.parseMainConf() 340 sat_conf = config.parseMainConf()
292 # if there are user defined extension, we use them 341 # if there are user defined extension, we use them
317 content_file_obj.seek(0) 366 content_file_obj.seek(0)
318 else: 367 else:
319 mb_data = None 368 mb_data = None
320 if item_lower == 'current': 369 if item_lower == 'current':
321 # use wants to continue current draft 370 # use wants to continue current draft
322 content_file_path = self.getCurrentFile(sat_conf) 371 content_file_path = self.getCurrentFile(sat_conf, self.profile)
323 self.disp(u'Continuing edition of current draft', 2) 372 self.disp(u'Continuing edition of current draft', 2)
324 else: 373 else:
325 # for now we taxe the item as a file path 374 # for now we taxe the item as a file path
326 content_file_path = os.path.expanduser(self.args.item) 375 content_file_path = os.path.expanduser(self.args.item)
327 content_file_obj = open(content_file_path, 'r+b') 376 content_file_obj = open(content_file_path, 'r+b')
332 381
333 382
334 class Preview(base.CommandBase, BlogCommon): 383 class Preview(base.CommandBase, BlogCommon):
335 384
336 def __init__(self, host): 385 def __init__(self, host):
337 super(Preview, self).__init__(host, 'preview', use_verbose=True, help=_(u'preview a blog content')) 386 base.CommandBase.__init__(self, host, 'preview', use_verbose=True, help=_(u'preview a blog content'))
387 BlogCommon.__init__(self, self.host)
338 388
339 def add_parser_options(self): 389 def add_parser_options(self):
340 self.parser.add_argument("--inotify", type=str, choices=('auto', 'true', 'false'), default=u'auto', help=_(u"use inotify to handle preview")) 390 self.parser.add_argument("--inotify", type=str, choices=('auto', 'true', 'false'), default=u'auto', help=_(u"use inotify to handle preview"))
341 self.parser.add_argument("file", type=base.unicode_decoder, nargs='?', default=u'current', help=_(u"path to the content file")) 391 self.parser.add_argument("file", type=base.unicode_decoder, nargs='?', default=u'current', help=_(u"path to the content file"))
342 392
419 else: 469 else:
420 update_cb = self.updatePreviewExt 470 update_cb = self.updatePreviewExt
421 471
422 # which file do we need to edit? 472 # which file do we need to edit?
423 if self.args.file == 'current': 473 if self.args.file == 'current':
424 self.content_file_path = self.getCurrentFile(sat_conf) 474 self.content_file_path = self.getCurrentFile(sat_conf, self.profile)
425 else: 475 else:
426 self.content_file_path = os.path.abspath(self.args.file) 476 self.content_file_path = os.path.abspath(self.args.file)
427 477
428 self.syntax = self.guessSyntaxFromPath(sat_conf, self.content_file_path) 478 self.syntax = self.guessSyntaxFromPath(sat_conf, self.content_file_path)
429 479