comparison piecrust/admin/views/micropub.py @ 969:5b735229b6fb

admin: Micropub improvements. - Add support for Micropub media endpoint. - Add support for uploading photos via the endpoint. - Fix URL returned after creating post. - Hack for Micro.blog access token problems. - Hack for bug in Flask-IndieAuth.
author Ludovic Chabant <ludovic@chabant.com>
date Sun, 08 Oct 2017 09:32:33 -0700
parents d65838abbd90
children 660250c95246
comparison
equal deleted inserted replaced
968:20f49786937c 969:5b735229b6fb
1 import re 1 import re
2 import os 2 import os
3 import os.path 3 import os.path
4 import json
5 import uuid
4 import logging 6 import logging
5 import datetime 7 import datetime
6 import yaml 8 import yaml
7 from werkzeug.utils import secure_filename 9 from werkzeug.utils import secure_filename
8 from flask import g, request, abort, Response 10 from flask import g, url_for, request, abort, jsonify, Response
9 from flask_indieauth import requires_indieauth 11 from flask_indieauth import requires_indieauth
10 from ..blueprint import foodtruck_bp 12 from ..blueprint import foodtruck_bp
13 from piecrust import CACHE_DIR
11 from piecrust.configuration import merge_dicts 14 from piecrust.configuration import merge_dicts
12 from piecrust.page import Page 15 from piecrust.page import Page
13 16
14 17
15 logger = logging.getLogger(__name__) 18 logger = logging.getLogger(__name__)
16 19
17 re_unsafe_asset_char = re.compile('[^a-zA-Z0-9_]') 20 re_unsafe_asset_char = re.compile('[^a-zA-Z0-9_]')
18 21
19 22
23 def _patch_flask_indieauth():
24 import flask_indieauth
25
26 def _patched_get_access_token_from_json_request(request):
27 try:
28 jsondata = json.loads(request.get_data(as_text=True))
29 return jsondata['access_token']
30 except ValueError:
31 return None
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 = \
42 _patched_get_access_token_from_json_request
43 flask_indieauth.check_auth = _patched_check_auth
44 logger.info("Patched Flask-IndieAuth.")
45
46
47 _patch_flask_indieauth()
48
49
50 _enable_debug_auth = False
51
52
53 def _debug_auth():
54 if _enable_debug_auth:
55 logger.warning("Headers: %s" % request.headers)
56 logger.warning("Args: %s" % request.args)
57 logger.warning("Form: %s" % request.form)
58 logger.warning("Data: %s" % request.get_data(True))
59
60
20 @foodtruck_bp.route('/micropub', methods=['POST']) 61 @foodtruck_bp.route('/micropub', methods=['POST'])
21 @requires_indieauth 62 @requires_indieauth
22 def micropub(): 63 def post_micropub():
64 _debug_auth()
65
23 post_type = request.form.get('h') 66 post_type = request.form.get('h')
24 67
25 if post_type == 'entry': 68 if post_type == 'entry':
26 uri = _create_hentry() 69 source_name, content_item = _create_hentry()
27 _run_publisher() 70 _run_publisher()
28 return _get_location_response(uri) 71 return _get_location_response(source_name, content_item)
29 72
30 logger.debug("Unknown or unsupported update type.") 73 logger.debug("Unknown or unsupported update type.")
31 logger.debug(request.form) 74 logger.debug(request.form)
32 abort(400) 75 abort(400)
76
77
78 @foodtruck_bp.route('/micropub/media', methods=['POST'])
79 @requires_indieauth
80 def post_micropub_media():
81 _debug_auth()
82 photo = request.files.get('file')
83 if not photo:
84 logger.error("Micropub media request without a file part.")
85 abort(400)
86 return
87
88 fn = secure_filename(photo.filename)
89 fn = re_unsafe_asset_char.sub('_', fn)
90 fn = '%s_%s' % (str(uuid.uuid1()), fn)
91
92 photo_cache_dir = os.path.join(
93 g.site.root_dir,
94 CACHE_DIR, g.site.piecrust_factory.cache_key,
95 'uploads')
96 try:
97 os.makedirs(photo_cache_dir, mode=0o775, exist_ok=True)
98 except OSError:
99 pass
100
101 photo_path = os.path.join(photo_cache_dir, fn)
102 logger.info("Uploading file to: %s" % photo_path)
103 photo.save(photo_path)
104
105 r = Response()
106 r.status_code = 201
107 r.headers.add('Location', fn)
108 return r
109
110
111 @foodtruck_bp.route('/micropub', methods=['GET'])
112 def get_micropub():
113 data = {}
114 if request.args.get('q') == 'config':
115 endpoint_url = (request.host_url.rstrip('/') +
116 url_for('.post_micropub_media'))
117 data.update({
118 "media-endpoint": endpoint_url
119 })
120
121 pcapp = g.site.piecrust_app
122 syn_data = pcapp.config.get('micropub/syndicate_to')
123 if syn_data:
124 data['syndicate-to'] = syn_data
125
126 return jsonify(**data)
33 127
34 128
35 def _run_publisher(): 129 def _run_publisher():
36 pcapp = g.site.piecrust_app 130 pcapp = g.site.piecrust_app
37 target = pcapp.config.get('micropub/publish_target') 131 target = pcapp.config.get('micropub/publish_target')
38 if target: 132 if target:
39 logger.debug("Running pushing target '%s'." % target) 133 logger.debug("Running pushing target '%s'." % target)
40 g.site.publish(target) 134 g.site.publish(target)
41 135
42 136
43 def _get_location_response(uri): 137 def _get_location_response(source_name, content_item):
138 from piecrust.app import PieCrust
139 pcapp = PieCrust(g.site.root_dir)
140 source = pcapp.getSource(source_name)
141
142 page = Page(source, content_item)
143 uri = page.getUri()
144
44 logger.debug("Redirecting to: %s" % uri) 145 logger.debug("Redirecting to: %s" % uri)
45 r = Response() 146 r = Response()
46 r.status_code = 201 147 r.status_code = 201
47 r.headers.add('Location', uri) 148 r.headers.add('Location', uri)
48 return r 149 return r
49 150
50 151
51 def _create_hentry(): 152 def _create_hentry():
52 f = request.form 153 f = request.form
53 pcapp = g.site.piecrust_app
54 154
55 summary = f.get('summary') 155 summary = f.get('summary')
56 categories = f.getlist('category[]') 156 categories = f.getlist('category[]')
57 location = f.get('location') 157 location = f.get('location')
58 reply_to = f.get('in-reply-to') 158 reply_to = f.get('in-reply-to')
118 if content_item is None: 218 if content_item is None:
119 logger.error("Can't create item for: %s" % metadata) 219 logger.error("Can't create item for: %s" % metadata)
120 abort(500) 220 abort(500)
121 221
122 # TODO: add proper APIs for creating related assets. 222 # TODO: add proper APIs for creating related assets.
123 photo_names = None 223 photo_names = []
124 if photos: 224 if photo_urls or photos:
125 photo_dir, _ = os.path.splitext(content_item.spec) 225 photo_dir, _ = os.path.splitext(content_item.spec)
126 photo_dir += '-assets' 226 photo_dir += '-assets'
127 if not os.path.exists(photo_dir): 227 try:
128 os.makedirs(photo_dir) 228 os.makedirs(photo_dir, mode=0o775, exist_ok=True)
129 229 except OSError:
130 photo_names = [] 230 pass
231
232 # Photo URLs come from files uploaded via the media endpoint...
233 # They're waiting for us in the upload cache folder, so let's
234 # move them to the post's assets folder.
235 if photo_urls:
236 photo_cache_dir = os.path.join(
237 g.site.root_dir,
238 CACHE_DIR, g.site.piecrust_factory.cache_key,
239 'uploads')
240
241 for p_url in photo_urls:
242 _, __, p_url = p_url.rpartition('/')
243 p_path = os.path.join(photo_cache_dir, p_url)
244 p_uuid, p_fn = p_url.split('_', 1)
245 p_asset = os.path.join(photo_dir, p_fn)
246 logger.info("Moving upload '%s' to '%s'." % (p_path, p_asset))
247 os.rename(p_path, p_asset)
248
249 p_fn_no_ext, _ = os.path.splitext(p_fn)
250 photo_names.append(p_fn_no_ext)
251
252 # There could also be some files uploaded along with the post
253 # so upload them right now.
254 if photos:
131 for photo in photos: 255 for photo in photos:
132 if not photo or not photo.filename: 256 if not photo or not photo.filename:
133 logger.warning("Got empty photo in request files... skipping.") 257 logger.warning("Got empty photo in request files... skipping.")
134 continue 258 continue
135 259
176 fp.write(summary) 300 fp.write(summary)
177 fp.write('\n') 301 fp.write('\n')
178 fp.write('<!--break-->\n\n') 302 fp.write('<!--break-->\n\n')
179 fp.write(content) 303 fp.write(content)
180 304
181 if photo_urls:
182 fp.write('\n\n')
183 for pu in photo_urls:
184 fp.write('<img src="{{assets.%s}}" alt=""/>\n\n' % pu)
185
186 if photo_names: 305 if photo_names:
187 fp.write('\n\n') 306 fp.write('\n\n')
188 for pn in photo_names: 307 for pn in photo_names:
189 fp.write('<img src="{{assets.%s}}" alt="%s"/>\n\n' % 308 fp.write('<img src="{{assets.%s}}" alt="%s"/>\n\n' %
190 (pn, pn)) 309 (pn, pn))
191 310
192 page = Page(source, content_item) 311 if os.supports_fd:
193 uri = page.getUri() 312 import stat
194 return uri 313 try:
195 314 os.chmod(fp.fileno(),
315 stat.S_IRUSR|stat.S_IWUSR|stat.S_IRGRP|stat.S_IWGRP)
316 except OSError:
317 pass
318
319 return source_name, content_item
320