comparison frontends/src/jp/common.py @ 2269:606ff34d30f2

jp (blog, common): moved and improved edit code from blog: - a new "common" module is there for code commonly used in commands - moved code for editing item with $EDITOR there - moved code to identify item to edit there - aforementioned fontions have been made generic - a class BaseEdit is now available to implement edition - HTTPS links are handled (only HTTP links were working before) - item can be use if all previous methods fail (url, keyword, file path).
author Goffi <goffi@goffi.org>
date Tue, 27 Jun 2017 16:23:28 +0200
parents
children 07caa12be945
comparison
equal deleted inserted replaced
2268:a29d1351bc83 2269:606ff34d30f2
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # jp: a SàT command line tool
5 # Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat_frontends.jp.constants import Const as C
21 from sat.core.i18n import _
22 from sat.tools.common import regex
23 from sat.tools import config
24 from ConfigParser import NoSectionError, NoOptionError
25 import json
26 import os
27 import os.path
28 import time
29 import tempfile
30 import subprocess
31 import glob
32
33 # defaut arguments used for some known editors (editing with metadata)
34 VIM_SPLIT_ARGS = "-c 'vsplit|wincmd w|next|wincmd w'"
35 EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"'
36 EDITOR_ARGS_MAGIC = {
37 'vim': VIM_SPLIT_ARGS + ' {content_file} {metadata_file}',
38 'gvim': VIM_SPLIT_ARGS + ' --nofork {content_file} {metadata_file}',
39 'emacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}',
40 'xemacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}',
41 'nano': ' -F {content_file} {metadata_file}',
42 }
43
44 SECURE_UNLINK_MAX = 10
45 SECURE_UNLINK_DIR = ".backup"
46 METADATA_SUFF = '_metadata.json'
47
48
49 def getTmpDir(sat_conf, cat_dir, sub_dir=None):
50 """Return directory used to store temporary files
51
52 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
53 @param cat_dir(str): directory of the category (e.g. "blog")
54 @param sub_dir(str): sub directory where data need to be put
55 profile can be used here, or special directory name
56 sub_dir will be escaped to be usable in path (use regex.pathUnescape to find
57 initial str)
58 @return (str): path to the dir
59 """
60 local_dir = config.getConfig(sat_conf, '', 'local_dir', Exception)
61 path = [local_dir, cat_dir]
62 if sub_dir is not None:
63 path.append(regex.pathEscape(sub_dir))
64 return os.path.join(*path)
65
66
67 class BaseEdit(object):
68 u"""base class for editing commands
69
70 This class allows to edit file for PubSub or something else.
71 It works with temporary files in SàT local_dir, in a "cat_dir" subdir
72 """
73
74 def __init__(self, host, cat_dir, use_metadata=True):
75 """
76 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
77 @param cat_dir(unicode): directory to use for drafts
78 this will be a sub-directory of SàT's local_dir
79 @param use_metadata(bool): True is edition need a second file for metadata
80 most of signature change with use_metadata with an additional metadata argument.
81 This is done to raise error if a command needs metadata but forget the flag, and vice versa
82 """
83 self.host = host
84 self.sat_conf = config.parseMainConf()
85 self.cat_dir = cat_dir.encode('utf-8')
86 self.use_metadata = use_metadata
87
88 def secureUnlink(self, path):
89 """Unlink given path after keeping it for a while
90
91 This method is used to prevent accidental deletion of a draft
92 If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX,
93 older file are deleted
94 @param path(str): file to unlink
95 """
96 if not os.path.isfile(path):
97 raise OSError(u"path must link to a regular file")
98 if not path.startswith(getTmpDir(self.sat_conf, self.cat_dir)):
99 self.disp(u"File {} is not in SàT temporary hierarchy, we do not remove it".format(path.decode('utf-8')), 2)
100 return
101 # we have 2 files per draft with use_metadata, so we double max
102 unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX
103 backup_dir = getTmpDir(self.sat_conf, self.cat_dir, SECURE_UNLINK_DIR)
104 if not os.path.exists(backup_dir):
105 os.makedirs(backup_dir)
106 filename = os.path.basename(path)
107 backup_path = os.path.join(backup_dir, filename)
108 # we move file to backup dir
109 self.host.disp(u"Backuping file {src} to {dst}".format(
110 src=path.decode('utf-8'), dst=backup_path.decode('utf-8')), 1)
111 os.rename(path, backup_path)
112 # and if we exceeded the limit, we remove older file
113 backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)]
114 if len(backup_files) > unlink_max:
115 backup_files.sort(key=lambda path: os.stat(path).st_mtime)
116 for path in backup_files[:len(backup_files) - unlink_max]:
117 self.host.disp(u"Purging backup file {}".format(path.decode('utf-8')), 2)
118 os.unlink(path)
119
120 def runEditor(self, editor_args_opt, content_file_path,
121 content_file_obj, meta_file_path=None, meta_ori=None):
122 """run editor to edit content and metadata
123
124 @param editor_args_opt(unicode): option in [jp] section in configuration for
125 specific args
126 @param content_file_path(str): path to the content file
127 @param content_file_obj(file): opened file instance
128 @param meta_file_path(str, None): metadata file path
129 if None metadata will not be used
130 @param meta_ori(dict, None): original cotent of metadata
131 can't be used if use_metadata is False
132 """
133 if not self.use_metadata:
134 assert meta_file_path is None
135 assert meta_ori is None
136
137 # we calculate hashes to check for modifications
138 import hashlib
139 content_file_obj.seek(0)
140 tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest()
141 content_file_obj.close()
142
143 # we prepare arguments
144 editor = config.getConfig(self.sat_conf, 'jp', 'editor') or os.getenv('EDITOR', 'vi')
145 try:
146 # is there custom arguments in sat.conf ?
147 editor_args = config.getConfig(self.sat_conf, 'jp', editor_args_opt, Exception)
148 except (NoOptionError, NoSectionError):
149 # no, we check if we know the editor and have special arguments
150 editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), '')
151 parse_kwargs = {'content_file': content_file_path}
152 if self.use_metadata:
153 parse_kwargs['metadata_file'] = meta_file_path
154 args = self.parse_args(editor_args, **parse_kwargs)
155 if not args:
156 args = [content_file_path]
157
158 # actual editing
159 editor_exit = subprocess.call([editor] + args)
160
161 # edition will now be checked, and data will be sent if it was a success
162 if editor_exit != 0:
163 self.disp(u"Editor exited with an error code, so temporary file has not be deleted, and item is not published.\nYou can find temporary file at {path}".format(
164 path=content_file_path), error=True)
165 else:
166 # main content
167 try:
168 with open(content_file_path, 'rb') as f:
169 content = f.read()
170 except (OSError, IOError):
171 self.disp(u"Can read file at {content_path}, have it been deleted?\nCancelling edition".format(
172 content_path=content_file_path), error=True)
173 self.host.quit(C.EXIT_NOT_FOUND)
174
175 # metadata
176 if self.use_metadata:
177 try:
178 with open(meta_file_path, 'rb') as f:
179 metadata = json.load(f)
180 except (OSError, IOError):
181 self.disp(u"Can read file at {meta_file_path}, have it been deleted?\nCancelling edition".format(
182 content_path=content_file_path, meta_path=meta_file_path), error=True)
183 self.host.quit(C.EXIT_NOT_FOUND)
184 except ValueError:
185 self.disp(u"Can't parse metadata, please check it is correct JSON format. Cancelling edition.\n" +
186 "You can find tmp file at {content_path} and temporary meta file at {meta_path}.".format(
187 content_path=content_file_path,
188 meta_path=meta_file_path), error=True)
189 self.host.quit(C.EXIT_DATA_ERROR)
190
191 if self.use_metadata and not C.bool(metadata.get('publish', "true")):
192 self.disp(u'Publication blocked by "publish" key in metadata, cancelling edition.\n\n' +
193 "temporary file path:\t{content_path}\nmetadata file path:\t{meta_path}".format(
194 content_path=content_file_path, meta_path=meta_file_path), error=True)
195 self.host.quit()
196
197 if len(content) == 0:
198 self.disp(u"Content is empty, cancelling the blog edition")
199 if not content_file_path.startswith(getTmpDir(self.sat_conf, self.cat_dir)):
200 self.disp(u"File are not in SàT temporary hierarchy, we do not remove them", 2)
201 self.host.quit()
202 self.disp(u"Deletion of {}".format(content_file_path.decode('utf-8')), 2)
203 os.unlink(content_file_path)
204 self.disp(u"Deletion of {}".format(meta_file_path.decode('utf-8')), 2)
205 os.unlink(meta_file_path)
206 self.host.quit()
207
208 # time to re-check the hash
209 elif (tmp_ori_hash == hashlib.sha1(content).digest() and
210 (not self.use_metadata or meta_ori == metadata)):
211 self.disp(u"The content has not been modified, cancelling the blog edition")
212 self.host.quit()
213
214 else:
215 # we can now send the item
216 content = content.decode('utf-8-sig') # we use utf-8-sig to avoid BOM
217 try:
218 if self.use_metadata:
219 self.publish(content, metadata)
220 else:
221 self.publish(content)
222 except Exception as e:
223 if self.use_metadata:
224 self.disp(u"Error while sending your item, the temporary files have been kept at {content_path} and {meta_path}: {reason}".format(
225 content_path=content_file_path, meta_path=meta_file_path, reason=e), error=True)
226 else:
227 self.disp(u"Error while sending your item, the temporary file has been kept at {content_path}: {reason}".format(
228 content_path=content_file_path, reason=e), error=True)
229 self.host.quit(1)
230
231 self.secureUnlink(content_file_path)
232 self.secureUnlink(meta_file_path)
233
234 def publish(self, content):
235 # if metadata is needed, publish will be called with it last argument
236 raise NotImplementedError
237
238 def getTmpFile(self, suff):
239 """Create a temporary file
240
241 @param suff (str): suffix to use for the filename
242 @return (tuple(file, str)): opened (w+b) file object and file path
243 """
244 tmp_dir = getTmpDir(self.sat_conf, self.cat_dir, self.profile.encode('utf-8'))
245 if not os.path.exists(tmp_dir):
246 try:
247 os.makedirs(tmp_dir)
248 except OSError as e:
249 self.disp(u"Can't create {path} directory: {reason}".format(
250 path=tmp_dir, reason=e), error=True)
251 self.host.quit(1)
252 try:
253 fd, path = tempfile.mkstemp(suffix=suff,
254 prefix=time.strftime(self.cat_dir.encode('utf-8') + '_%Y-%m-%d_%H:%M:%S_'),
255 dir=tmp_dir, text=True)
256 return os.fdopen(fd, 'w+b'), path
257 except OSError as e:
258 self.disp(u"Can't create temporary file: {reason}".format(reason=e), error=True)
259 self.host.quit(1)
260
261 def getCurrentFile(self, profile):
262 """Get most recently edited file
263
264 @param profile(unicode): profile linked to the draft
265 @return(str): full path of current file
266 """
267 # we guess the blog item currently edited by choosing
268 # the most recent file corresponding to temp file pattern
269 # in tmp_dir, excluding metadata files
270 cat_dir_str = self.cat_dir.encode('utf-8')
271 tmp_dir = getTmpDir(self.sat_conf, cat_dir_str, profile.encode('utf-8'))
272 available = [path for path in glob.glob(os.path.join(tmp_dir, cat_dir_str + '_*')) if not path.endswith(METADATA_SUFF)]
273 if not available:
274 self.disp(u"Could not find any content draft in {path}".format(path=tmp_dir), error=True)
275 self.host.quit(1)
276 return max(available, key=lambda path: os.stat(path).st_mtime)
277
278 def getItemData(self, service, node, item):
279 """return formatted content and metadata (or not if use_metadata is false)"""
280 raise NotImplementedError
281
282 def getTmpSuff(self):
283 """return suffix used for content file"""
284 return 'xml'
285
286 def getItemPath(self, item):
287 """retrieve item path (i.e. service and node) from item argument
288
289 This method is obviously only useful for edition of PubSub based features
290 service, node and item must be named like this in args
291 @param item(unicode): item to get or url or magic keyword
292 item argument can be used to specify :
293 - HTTP(S) URL
294 - XMPP URL
295 - keyword, which can be:
296 - new: create new item
297 - last: retrieve last published item
298 - current: continue current local draft
299 - file path
300 - item id
301 """
302 command = item.lower()
303 pubsub_service = self.args.service
304 pubsub_node = self.args.node
305 pubsub_item = None
306
307 if command not in ('new', 'last', 'current'):
308 # we have probably an URL, we try to parse it
309 import urlparse
310 url = self.args.item
311 parsed_url = urlparse.urlsplit(url)
312 if parsed_url.scheme.startswith('http'):
313 self.disp(u"{} URL found, trying to find associated xmpp: URI".format(parsed_url.scheme.upper()),1)
314 # HTTP URL, we try to find xmpp: links
315 try:
316 from lxml import etree
317 except ImportError:
318 self.disp(u"lxml module must be installed to use http(s) scheme, please install it with \"pip install lxml\"", error=True)
319 self.host.quit(1)
320 import urllib2
321 parser = etree.HTMLParser()
322 try:
323 root = etree.parse(urllib2.urlopen(url), parser)
324 except etree.XMLSyntaxError as e:
325 self.disp(_(u"Can't parse HTML page : {msg}").format(msg=e))
326 links = []
327 else:
328 links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]")
329 if not links:
330 self.disp(u'Could not find alternate "xmpp:" URI, can\'t find associated XMPP PubSub node/item', error=True)
331 self.host.quit(1)
332 url = links[0].get('href')
333 parsed_url = urlparse.urlsplit(url)
334
335 if parsed_url.scheme == 'xmpp':
336 if self.args.service or self.args.node:
337 self.parser.error(_(u"You can't use URI and --service or --node at the same time"))
338
339 self.disp(u"XMPP URI used: {}".format(url),2)
340 # XXX: if we have not xmpp: URI here, we'll take the data as a file path
341 pubsub_service = parsed_url.path
342 pubsub_data = urlparse.parse_qs(parsed_url.query)
343 try:
344 pubsub_node = pubsub_data['node'][0]
345 except KeyError:
346 self.disp(u'No node found in xmpp: URI, can\'t retrieve item', error=True)
347 self.host.quit(1)
348 pubsub_item = pubsub_data.get('item',[None])[0]
349 if pubsub_item is not None:
350 command = 'edit' # XXX: edit command is only used internaly, it similar to last, but with the item given in the URL
351 else:
352 command = 'new'
353
354 if command in ('new', 'last', 'edit'):
355 # we need a temporary file
356 tmp_suff = '.' + self.getTmpSuff()
357 content_file_obj, content_file_path = self.getTmpFile(tmp_suff)
358 if command == 'new':
359 self.disp(u'Editing a new item', 2)
360 if self.use_metadata:
361 metadata = None
362 elif command in ('last', 'edit'):
363 self.disp(u'Editing requested published item', 2)
364 try:
365 if self.use_metadata:
366 content, metadata = self.getItemData(pubsub_service, pubsub_node, pubsub_item)
367 else:
368 content = self.getItemData(pubsub_service, pubsub_node, pubsub_item)
369 except Exception as e:
370 self.disp(u"Error while retrieving last item: {}".format(e))
371 self.host.quit(1)
372 content_file_obj.write(content.encode('utf-8'))
373 content_file_obj.seek(0)
374 else:
375 if self.use_metadata:
376 metadata = None
377 if command == 'current':
378 # user wants to continue current draft
379 content_file_path = self.getCurrentFile(self.profile)
380 self.disp(u'Continuing edition of current draft', 2)
381 content_file_obj = open(content_file_path, 'r+b')
382 elif os.path.isfile(self.args.item):
383 # there is an existing draft that we use
384 content_file_path = os.path.expanduser(self.args.item)
385 content_file_obj = open(content_file_path, 'r+b')
386 else:
387 # last chance, it should be an item
388 tmp_suff = '.' + self.getTmpSuff()
389 content_file_obj, content_file_path = self.getTmpFile(tmp_suff)
390
391 if self.use_metadata:
392 content, metadata = self.getItemData(pubsub_service, pubsub_node, self.args.item)
393 else:
394 content = self.getItemData(pubsub_service, pubsub_node, self.args.item)
395 content_file_obj.write(content.encode('utf-8'))
396 content_file_obj.seek(0)
397
398 if self.use_metadata:
399 return pubsub_service, pubsub_node, content_file_path, content_file_obj, metadata
400 else:
401 return pubsub_service, pubsub_node, content_file_path, content_file_obj