Mercurial > piecrust2
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') |