comparison piecrust/admin/views/micropub.py @ 970:660250c95246

admin: Improve support for Micropub. - Handle Micropub JSON syntax. - Use UTF8 encoding for created posts. - Remove Micro.blog hack for authorization.
author Ludovic Chabant <ludovic@chabant.com>
date Mon, 09 Oct 2017 21:07:00 -0700
parents 5b735229b6fb
children 60b431c57ea9
comparison
equal deleted inserted replaced
969:5b735229b6fb 970:660250c95246
28 jsondata = json.loads(request.get_data(as_text=True)) 28 jsondata = json.loads(request.get_data(as_text=True))
29 return jsondata['access_token'] 29 return jsondata['access_token']
30 except ValueError: 30 except ValueError:
31 return None 31 return None
32 32
33 _orig_check_auth = flask_indieauth.check_auth
34
35 def _patched_check_auth(access_token):
36 user_agent = request.headers.get('User-Agent') or ''
37 if user_agent.startswith('Micro.blog/'):
38 return None
39 return _orig_check_auth(access_token)
40
41 flask_indieauth.get_access_token_from_json_request = \ 33 flask_indieauth.get_access_token_from_json_request = \
42 _patched_get_access_token_from_json_request 34 _patched_get_access_token_from_json_request
43 flask_indieauth.check_auth = _patched_check_auth
44 logger.info("Patched Flask-IndieAuth.") 35 logger.info("Patched Flask-IndieAuth.")
45 36
46 37
47 _patch_flask_indieauth() 38 _patch_flask_indieauth()
48 39
49 40
50 _enable_debug_auth = False 41 _enable_debug_req = False
51 42
52 43
53 def _debug_auth(): 44 def _debug_req():
54 if _enable_debug_auth: 45 if _enable_debug_req:
55 logger.warning("Headers: %s" % request.headers) 46 logger.warning("Headers: %s" % request.headers)
56 logger.warning("Args: %s" % request.args) 47 logger.warning("Args: %s" % request.args)
57 logger.warning("Form: %s" % request.form) 48 logger.warning("Form: %s" % request.form)
58 logger.warning("Data: %s" % request.get_data(True)) 49 logger.warning("Data: %s" % request.get_data(True))
50 try:
51 logger.warning("JSON: %s" % request.json)
52 except:
53 pass
59 54
60 55
61 @foodtruck_bp.route('/micropub', methods=['POST']) 56 @foodtruck_bp.route('/micropub', methods=['POST'])
62 @requires_indieauth 57 @requires_indieauth
63 def post_micropub(): 58 def post_micropub():
64 _debug_auth() 59 _debug_req()
65 60
66 post_type = request.form.get('h') 61 if 'h' in request.form:
67 62 data = _get_mf2_from_form(request.form)
68 if post_type == 'entry': 63 else:
69 source_name, content_item = _create_hentry() 64 try:
70 _run_publisher() 65 data = json.loads(request.get_data(as_text=True))
71 return _get_location_response(source_name, content_item) 66 except:
72 67 data = None
73 logger.debug("Unknown or unsupported update type.") 68
74 logger.debug(request.form) 69 if data:
70 entry_type = _mf2get(data, 'type')
71 if entry_type == 'h-entry':
72 source_name, content_item = _create_hentry(data['properties'])
73 _run_publisher()
74 return _get_location_response(source_name, content_item)
75
76 else:
77 logger.error("Post type '%s' is not supported." % post_type)
78 else:
79 logger.error("Missing form or JSON data.")
80
75 abort(400) 81 abort(400)
76 82
77 83
78 @foodtruck_bp.route('/micropub/media', methods=['POST']) 84 @foodtruck_bp.route('/micropub/media', methods=['POST'])
79 @requires_indieauth 85 @requires_indieauth
80 def post_micropub_media(): 86 def post_micropub_media():
81 _debug_auth() 87 _debug_req()
88
82 photo = request.files.get('file') 89 photo = request.files.get('file')
83 if not photo: 90 if not photo:
84 logger.error("Micropub media request without a file part.") 91 logger.error("Micropub media request without a file part.")
85 abort(400) 92 abort(400)
86 return 93 return
147 r.status_code = 201 154 r.status_code = 201
148 r.headers.add('Location', uri) 155 r.headers.add('Location', uri)
149 return r 156 return r
150 157
151 158
152 def _create_hentry(): 159 re_array_prop = re.compile(r'\[(?P<name>\w*)\]$')
153 f = request.form 160
154 161
155 summary = f.get('summary') 162 def _get_mf2_from_form(f):
156 categories = f.getlist('category[]') 163 post_type = 'h-' + f.get('h', '')
157 location = f.get('location') 164
158 reply_to = f.get('in-reply-to') 165 properties = {}
159 status = f.get('post-status') 166 for key, vals in f.lists():
160 # pubdate = f.get('published', 'now') 167 m = re_array_prop.search(key)
161 168 if not m:
162 # Figure out the title of the post. 169 properties[key] = vals
163 name = f.get('name') 170 continue
164 if not name: 171
165 name = f.get('name[]') 172 key_name_only = key[:m.start()]
166 173 inner_name = m.group('name')
167 # Figure out the contents of the post. 174 if not inner_name:
175 properties[key_name_only] = vals
176 continue
177
178 properties[key_name_only] = [{inner_name: vals[0]}]
179
180 return {
181 'type': [post_type],
182 'properties': properties}
183
184
185 def _mf2get(data, key):
186 val = data.get(key)
187 if val is not None:
188 return val[0]
189 return None
190
191
192 def _create_hentry(data):
193 name = _mf2get(data, 'name')
194 summary = _mf2get(data, 'summary')
195 location = _mf2get(data, 'location')
196 reply_to = _mf2get(data, 'in-reply-to')
197 status = _mf2get(data, 'post-status')
198 # pubdate = _mf2get(data, 'published') or 'now'
199
200 categories = data.get('category')
201
202 # Get the content.
168 post_format = None 203 post_format = None
169 content = f.get('content') 204 content = _mf2get(data, 'content')
170 if not content: 205 if isinstance(content, dict):
171 content = f.get('content[]') 206 content = content.get('html')
172 if not content:
173 content = f.get('content[html]')
174 post_format = 'none' 207 post_format = 'none'
175
176 if not content: 208 if not content:
177 logger.error("No content specified!") 209 logger.error("No content specified!")
178 logger.error(dict(request.form)) 210 logger.error(data)
179 abort(400) 211 abort(400)
180 212
213 # Clean-up stuff.
181 # TODO: setting to conserve Windows-type line endings? 214 # TODO: setting to conserve Windows-type line endings?
182 content = content.replace('\r\n', '\n') 215 content = content.replace('\r\n', '\n')
183 if summary: 216 if summary:
184 summary = summary.replace('\r\n', '\n') 217 summary = summary.replace('\r\n', '\n')
185 218
186 # Figure out the slug of the post. 219 # Get the slug.
220 slug = _mf2get(data, 'slug') or _mf2get(data, 'mp-slug')
187 now = datetime.datetime.now() 221 now = datetime.datetime.now()
188 slug = f.get('slug')
189 if not slug:
190 slug = f.get('mp-slug')
191 if not slug: 222 if not slug:
192 slug = '%02d%02d%02d' % (now.hour, now.minute, now.second) 223 slug = '%02d%02d%02d' % (now.hour, now.minute, now.second)
193 224
194 # Get the media to attach to the post.
195 photo_urls = None
196 if 'photo' in f:
197 photo_urls = [f['photo']]
198 elif 'photo[]' in f:
199 photo_urls = f.getlist('photo[]')
200
201 photos = None
202 if 'photo' in request.files:
203 photos = [request.files['photo']]
204 elif 'photo[]' in request.files:
205 photos = request.files.getlist('photo[]')
206
207 # Create the post in the correct content source. 225 # Create the post in the correct content source.
226 # Note that this won't actually write anything to disk yet, we're
227 # just creating it in memory.
208 pcapp = g.site.piecrust_app 228 pcapp = g.site.piecrust_app
209 source_name = pcapp.config.get('micropub/source', 'posts') 229 source_name = pcapp.config.get('micropub/source', 'posts')
210 source = pcapp.getSource(source_name) 230 source = pcapp.getSource(source_name)
211 231
212 metadata = { 232 metadata = {
217 content_item = source.createContent(metadata) 237 content_item = source.createContent(metadata)
218 if content_item is None: 238 if content_item is None:
219 logger.error("Can't create item for: %s" % metadata) 239 logger.error("Can't create item for: %s" % metadata)
220 abort(500) 240 abort(500)
221 241
242 # Get the media to attach to the post.
243 photos = None
244 if 'photo' in request.files:
245 photos = [request.files['photo']]
246 elif 'photo[]' in request.files:
247 photos = request.files.getlist('photo[]')
248 photo_urls = data.get('photo')
249
250 # Create the assets folder if we have anything to put there.
222 # TODO: add proper APIs for creating related assets. 251 # TODO: add proper APIs for creating related assets.
223 photo_names = []
224 if photo_urls or photos: 252 if photo_urls or photos:
225 photo_dir, _ = os.path.splitext(content_item.spec) 253 photo_dir, _ = os.path.splitext(content_item.spec)
226 photo_dir += '-assets' 254 photo_dir += '-assets'
227 try: 255 try:
228 os.makedirs(photo_dir, mode=0o775, exist_ok=True) 256 os.makedirs(photo_dir, mode=0o775, exist_ok=True)
229 except OSError: 257 except OSError:
258 # An `OSError` can still be raised in older versions of Python
259 # if the permissions don't match an existing folder.
260 # Let's ignore it.
230 pass 261 pass
231 262
232 # Photo URLs come from files uploaded via the media endpoint... 263 # Photo URLs come from files uploaded via the media endpoint...
233 # They're waiting for us in the upload cache folder, so let's 264 # They're waiting for us in the upload cache folder, so let's
234 # move them to the post's assets folder. 265 # move them to the post's assets folder.
266 photo_names = []
235 if photo_urls: 267 if photo_urls:
236 photo_cache_dir = os.path.join( 268 photo_cache_dir = os.path.join(
237 g.site.root_dir, 269 g.site.root_dir,
238 CACHE_DIR, g.site.piecrust_factory.cache_key, 270 CACHE_DIR, g.site.piecrust_factory.cache_key,
239 'uploads') 271 'uploads')
242 _, __, p_url = p_url.rpartition('/') 274 _, __, p_url = p_url.rpartition('/')
243 p_path = os.path.join(photo_cache_dir, p_url) 275 p_path = os.path.join(photo_cache_dir, p_url)
244 p_uuid, p_fn = p_url.split('_', 1) 276 p_uuid, p_fn = p_url.split('_', 1)
245 p_asset = os.path.join(photo_dir, p_fn) 277 p_asset = os.path.join(photo_dir, p_fn)
246 logger.info("Moving upload '%s' to '%s'." % (p_path, p_asset)) 278 logger.info("Moving upload '%s' to '%s'." % (p_path, p_asset))
247 os.rename(p_path, p_asset) 279 try:
280 os.rename(p_path, p_asset)
281 except OSError:
282 logger.error("Can't move '%s' to '%s'." % (p_path, p_asset))
283 raise
248 284
249 p_fn_no_ext, _ = os.path.splitext(p_fn) 285 p_fn_no_ext, _ = os.path.splitext(p_fn)
250 photo_names.append(p_fn_no_ext) 286 photo_names.append(p_fn_no_ext)
251 287
252 # There could also be some files uploaded along with the post 288 # There could also be some files uploaded along with the post
274 post_config['tags'] = categories 310 post_config['tags'] = categories
275 if location: 311 if location:
276 post_config['location'] = location 312 post_config['location'] = location
277 if reply_to: 313 if reply_to:
278 post_config['reply_to'] = reply_to 314 post_config['reply_to'] = reply_to
279 if status: 315 if status and status != 'published':
280 post_config['status'] = status 316 post_config['draft'] = True
281 if post_format: 317 if post_format:
282 post_config['format'] = post_format 318 post_config['format'] = post_format
283 post_config['time'] = '%02d:%02d:%02d' % (now.hour, now.minute, now.second) 319 post_config['time'] = '%02d:%02d:%02d' % (now.hour, now.minute, now.second)
284 320
285 # If there's no title, this is a "microblogging" post. 321 # If there's no title, this is a "microblogging" post.
287 micro_config = pcapp.config.get('micropub/microblogging') 323 micro_config = pcapp.config.get('micropub/microblogging')
288 if micro_config: 324 if micro_config:
289 merge_dicts(post_config, micro_config) 325 merge_dicts(post_config, micro_config)
290 326
291 logger.debug("Writing to item: %s" % content_item.spec) 327 logger.debug("Writing to item: %s" % content_item.spec)
292 with source.openItem(content_item, mode='w') as fp: 328 with source.openItem(content_item, mode='w', encoding='utf8') as fp:
293 fp.write('---\n') 329 fp.write('---\n')
294 yaml.dump(post_config, fp, 330 yaml.dump(post_config, fp,
295 default_flow_style=False, 331 default_flow_style=False,
296 allow_unicode=True) 332 allow_unicode=True)
297 fp.write('---\n') 333 fp.write('---\n')