Nathan Grigg

Uploading an Image with Workflow and Flask

Workflow is an iOS app that lets you build a simple program by dragging blocks around, similar to Apple’s Automator app that ships with macOS. A recent update makes it possible to send a wider variety of HTTP requests, which allows you to interact with web APIs that aren’t otherwise supported.

Or, if you have a web server, write your own API.

Upload images workflow

Here is a workflow to take images from my phone and upload them to my server. It makes one request per image. It sets the custom header Grigg-Authentication to make sure that random people aren’t uploading images. It puts a file into the POST request with field name image. The responses will be HTML image tags, which are collected and then copied to the clipboard.

Flask

Flask is a Python web framework. It makes it very easy to map URLs to Python functions.

The first thing I wrote was a private decorator, that would check the HTTP headers for my authentication key. It doesn’t have to be a decorator, but that makes it easier to reuse in the future.

1
2
3
4
5
6
7
8
9
KEY = 'password1!'

def private(f):
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        if flask.request.headers.get('Grigg-Authenticate') != KEY:
            flask.abort(403)
        return f(*args, **kwargs)
    return wrapper

If you are not using a secure (HTTPS) connection, somebody could read your authentication key and pretend to be you. You can set this up directly with Flask, but since I’m already running nginx, I used that. (I will share the details in a future post.)

Next, there is some basic Flask setup. I changed the response MIME type to plain text and registered an error handler that will report any exceptions in the response, rather than logging an error where I won’t see it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
app = flask.Flask(__name__)

class TextResponse(flask.Response):
    default_mimetype = 'text/plain'

app.response_class = TextResponse

@app.errorhandler(Exception)
def handle_generic_exception(e):
    return 'Unhandled exception: {!r}\n'.format(e)

Then, there is the routing code. This function is called every time someone visits /blog/upload-image, as specified in the route decorator.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@app.route('/blog/upload-image', methods=['POST'])
@private
def blog_upload_image():
    try:
        fh = flask.request.files['image']
    except KeyError:
        flask.abort(400, 'Expected a file with key "image", not found')

    _, extension = os.path.splitext(fh.filename)
    filename = upload_image(fh, extension)
    return '<img src="{}" class="centered">\n'.format(filename)

Finally, the actual work is done by the upload_image function. I save the image into a dated directory with a random filename, then run a bunch of git commands.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Error(Exception):
    pass

def random_chars(size):
    return base64.b32encode(
            uuid.uuid4().bytes).decode('ascii').lower().rstrip('=')[:size]

def upload_image(fh, extension):
    """Upload image to blog and return filename, relative to site root."""
    subdir = 'images/{:%Y}'.format(datetime.datetime.today())
    try:
        os.mkdir(os.path.join(PATH, subdir))
    except FileExistsError:
        pass

    basename = ''.join((random_chars(8), extension))
    filename = os.path.join(subdir, basename)
    fh.save(os.path.join(PATH, filename))

    output = []
    def run(args):
        output.append(' '.join(args))
        output.append(subprocess.check_output(
            args, cwd=PATH, stderr=subprocess.STDOUT))
    try:
        run(['git', 'pull', '--ff-only'])
        run(['git', 'add', filename])
        run(['git', 'commit', '-m', 'Add image'])
        run(['git', 'push'])
    except subprocess.CalledProcessError:
        raise Error('Git operation failed. Output:\n{}'.format(
            '\n'.join(output)))

    return filename