/
app.py
591 lines (468 loc) · 21 KB
/
app.py
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
#
# A simple blogging platform initially based on
# https://charlesleifer.com/blog/how-to-make-a-flask-blog-in-one-hour-or-less/
# but extended considerably by me.
#
# TODO: add tag management (delete, rename, etc)
# TODO: change search algorithm
# TODO: add separate section for recipes
# TODO: add rss feed capability
# TODO: verify uploaded file filetype
##################
# Imports
##################
import datetime
import functools
import os
import re
import urllib
from flask import (Flask, abort, flash, Markup, redirect, render_template,
request, Response, session, url_for)
# used for rendering the article body
from markdown import markdown
from markdown.extensions.codehilite import CodeHiliteExtension
from markdown.extensions.extra import ExtraExtension
# micawber supplies a few methods for retrieving rich metadata about a variety of links, such as links to youtube videos
from micawber import bootstrap_basic, parse_html
from micawber.cache import Cache as OEmbedCache
# peewee and the playhoouse extensions deal with database management
from peewee import *
from playhouse.flask_utils import FlaskDB, get_object_or_404, object_list
from playhouse.sqlite_ext import *
from secret import *
##################
# Configs
##################
# REVIEW: change if not utilizing https
# ADMIN_PASSWORD = 'secret'
# os.path.realpath - Return the canonical path of the specified filename, eliminating any symbolic links encountered in the path
# __file__ - prints out the file location
APP_DIR = os.path.dirname(os.path.realpath(__file__))
DATABASE = 'sqliteext:///%s' % os.path.join(APP_DIR, 'blog.db')
DEBUG = False
# used by flask to encrypt the session cookie
# random value obtained from os.urandom
# SECRET_KEY = b'&iRy\xed{\x1aD\xb8\xef\xbc8\x02\n\xf3\x02"6\xc8~\x82o[\xc9'
SITE_WIDTH = 800
# user agent used by blog_entry_uploader.py
UPLOADER_USER_AGENT = 'tommy/post-uploader'
app = Flask(__name__)
# my best guess is that this is importing the config variables from this
# file in particular, so you can declare them cleanly as above without calling
# a bunch of specific methods
app.config.from_object(__name__)
# from http://docs.peewee-orm.com/en/latest/peewee/playhouse.html#flask-utils
# The FlaskDB class is a wrapper for configuring and referencing a Peewee database from within a Flask application.
# Don’t let its name fool you: it is not the same thing as a peewee database.
# FlaskDB is designed to remove the following boilerplate from your flask app:
# Dynamically create a Peewee database instance based on app config data.
# Create a base class from which all your application’s models will descend.
# Register hooks at the start and end of a request to handle opening and closing a database connection.
flask_db = FlaskDB(app)
database = flask_db.database
# as far as I can tell this loads provider information into micawber for converting
# to convert urls into embeddable content, I think micawber.Cache has basic stored
# info about these providers?
oembed_providers = bootstrap_basic(OEmbedCache())
##################
# Databse Classes
##################
# creates a model class for the entry table
class Entry(flask_db.Model):
# Field class for storing strings
title = CharField()
# from https://www.sqlitetutorial.net/sqlite-unique-constraint/
# A UNIQUE constraint ensures all values in a column or a group of columns
# are distinct from one another or unique.
# we're using this to create a URL-friendly version of the title
slug = CharField(unique=True)
summary = CharField()
# Field class for storing text.
content = TextField()
# from https://www.sqlitetutorial.net/sqlite-index/
# The index contains data from the columns that you specify in the index
# and the corresponding rowid value. This helps SQLite quickly locate the
# row based on the values of the indexed columns.
# we will use this to determine if the articles should be displayed on the
# site or not
published = BooleanField(index=True)
timestamp = DateTimeField(default=datetime.datetime.now, index=True)
@property
def html_content(self):
# used for code/syntax highlighting
# css_class -- name of css class used for div
hilite = CodeHiliteExtension(linenums=True, css_class='highlight')
# all the extensions found here:
# https://python-markdown.github.io/extensions/extra/
extras = ExtraExtension()
# utilizes the above extensions and converts the markdown to html
markdown_content = markdown(self.content, extensions=[hilite, extras])
# parse_html -- Parse HTML intelligently, rendering items on their own
# within block elements as full content (e.g. a video player)
# urlize_all -- constructs a simple link when provider is not found
oembed_content = parse_html(
markdown_content,
oembed_providers,
urlize_all=True,
maxwidth=app.config['SITE_WIDTH'])
# Markup returns a string that is ready to be safely inserted into
# an HTML document
return Markup(oembed_content)
def save(self, *args, **kwargs):
# replace the non-URL-friendly characters and put that in self.slug
if not self.slug:
# \w - matches any word character (alphanumeric & underscore)
# [^\w] - matches anything not in the set
# [^\w]+ - matches one or more of the preceding
self.slug = re.sub('[^\w]+', '-', self.title.lower())
self.update_summary()
# this explicity puts the super() arguments Entry, self when you can
# just say super()
# saves the Entry instance into the database
ret = super(Entry, self).save(*args, **kwargs)
# store search content
self.update_search_index()
# returns number of rows modified
return ret
def add_tags(self, *args):
new_tag_count = 0
new_entrytag_count = 0
for t in args:
# returns a tuple of model instance and boolean showing whether or
# not the entry was created
(tag, tag_created) = Tag.get_or_create(title = t)
if tag_created:
new_tag_count += 1
(_, entrytag_created) = EntryTag.get_or_create(entry = self, tag = tag)
if entrytag_created:
new_entrytag_count += 1
# returning for basic debug
return (new_tag_count, new_entrytag_count)
def get_tags(self):
try:
tags = [tag.tag.title for tag in self.tags]
except:
tags = []
finally:
return tags
# creates a basic 100 character summary
def update_summary(self):
matches = re.findall('[A-Za-z\s\,\.]+[^<*>]\w+', self.content[:200])
print(matches)
match = " ".join(matches)
self.summary = match[:100]
# updates the FTSEntry table used for fast searching of all articles
def update_search_index(self):
search_content = '\n'.join((self.title, self.content))
# check to see if there's already an FTSEntry for this article
try:
fts_entry = FTSEntry.get(FTSEntry.docid == self.id)
# if there's not one, create it
except FTSEntry.DoesNotExist:
# previously this had docid instead of rowid, but this (http://docs.peewee-orm.com/en/latest/peewee/sqlite_ext.html#FTSModel)
# there is an automatically created rowid
FTSEntry.create(rowid = self.id, content=search_content)
# if there is one, update the contents and save
else:
fts_entry.content = search_content
fts_entry.save()
# wrapper for super delete_instance
def delete_instance(self, *args, **kwargs):
ret = super(Entry, self).delete_instance(*args, **kwargs)
self.delete_search_index()
return ret
def delete_search_index(self):
fts_entry = FTSEntry.get(FTSEntry.docid == self.id)
fts_entry.delete_instance()
# Class methods are like instance methods, except that instead of the instance
# of an object being passed as the first positional self argument, the class
# itself is passed as the first argument.
# from https://www.geeksforgeeks.org/classmethod-in-python/:
# - A class method is a method which is bound to the class and not the object of the class.
# - They have the access to the state of the class as it takes a class parameter
# that points to the class and not the object instance.
# - It can modify a class state that would apply across all the instances of the
# class. For example, it can modify a class variable that would be applicable
# to all the instances.
@classmethod
def public(cls):
return Entry.select().where(Entry.published == True)
@classmethod
def drafts(cls):
return Entry.select().where(Entry.published == False)
@classmethod
def search(cls, query):
# not sure why the if statement is the same as the expression,
# this may be open for simplification (TODO)
words = [word.strip() for word in query.split() if word.strip()]
if not words:
# return empty query
return Entry.select().where(Entry.id == 0)
else:
search = ' '.join(words)
# select -- selects all columns on Entry, and a score (the alias) to
# the rank of each entry in matching the search string
# .match - Generate a SQL expression representing a search for the given term or expression in the table.
# order_by(SQL('score')) -- when using aliases, you must call them using
# SQL(), which is a helper function that runs arbitrary SQL
# NOTE: check this specification out for more info on what is supported
# with match - http://sqlite.org/fts3.html#section_3
return (Entry
.select(Entry, FTSEntry.rank().alias('score'))
.join(FTSEntry, on=(Entry.id == FTSEntry.docid))
.where(
(Entry.published == True) &
(FTSEntry.match(search)))
.order_by(SQL('score')))
# FTS stands for Full Text Search, which is an extension of SQLite's virtual
# table functionality
# from https://sqlite.org/vtab.html
# virtual tables - registered with an open database connection, a virtual table
# object looks like a table, but does not actually read or write to the database
# file, instead it could represent in-memory data structures or data on disk that
# is not in the SQLite database format
class FTSEntry(FTSModel):
# only for use in full-text search virtual tables
content = SearchField()
# some kind of strange thing peewee does to relate a database to the
# class without using the above method in the class inheritance field
class Meta:
database = database
# Implementing tags using the "Toxi" solution as suggested here:
# https://stackoverflow.com/a/20871
class Tag(flask_db.Model):
title = CharField(unique=True)
def delete_instance(self):
query = EntryTag.delete().where(EntryTag.tag == self)
query.execute()
return super(Tag, self).delete_instance()
@classmethod
def get_or_create(cls, **kwargs):
if kwargs['title']:
kwargs['title'] = Tag.sanitize_query(kwargs['title'])
return super(Tag, cls).get_or_create(**kwargs)
@classmethod
def search(cls, query):
sanitized_query = Tag.sanitize_query(query)
return (Entry
.select()
.join(EntryTag)
.where(EntryTag.tag == Tag.get(Tag.title == sanitized_query))
.order_by(Entry.timestamp.desc()) )
@staticmethod
def sanitize_query(query):
return re.sub('[^\w]+', '_', query.lower())
class EntryTag(flask_db.Model):
entry = ForeignKeyField(Entry, backref='tags')
tag = ForeignKeyField(Tag, backref='entries')
##################
# Application Functions
##################
# custom wrapper to redirect user to login page if they're trying to
# access an admin-only page
def login_required(fn):
# basically a wrapper that alters metadata to show the original function
# and not, in this case, the inner function, and also passes arguments
# from the original fn to the new function inner
@functools.wraps(fn)
def inner(*args, **kwargs):
# session is a flask object that behaves like a dict, but is really a
# signed cookie, and can be used to store information between requests
# 'logged_in' is a keyword with a possible True response
if session.get('logged_in'):
return fn(*args, **kwargs)
if request.headers.get('User-Agent') == app.config['UPLOADER_USER_AGENT']:
return {'logged_in': False}, 403
else:
# redirect returns a Response object that redirects the client to the
# target location
# url_for generates a url for the given endpoint, next is retrieved
# by the login method
# request.path -- the requested path as unicode
return redirect(url_for('login', next=request.path))
return inner
# i have no idea what this function does yet and can't seem to figure it out
# the only instance I have seen this used is in templates/includes/pagination.html
@app.template_filter('clean_querystring')
def clean_querystring(request_args, *keys_to_remove, **new_values):
querystring = dict((key, value) for key, value in request_args.items())
for key in keys_to_remove:
querystring.pop(key, None)
querystring.update(new_values)
# previously this had urllib.urlencode but it is now at the below
return urllib.parse.urlencode(querystring)
# errorhandler - Register a function to handle errors by code or exception class.
@app.errorhandler(404)
def not_found(exc):
return Response(render_template('404.html')), 404
##################
# Routes
##################
@app.route('/login/', methods=['GET', 'POST'])
def login():
next_url = request.args.get('next') or request.form.get('next')
# if the user is submitting the password for authentication
if request.method == 'POST' and request.form.get('password'):
password = request.form.get('password')
if password == app.config['ADMIN_PASSWORD']:
# set the value in the cookie
session['logged_in'] = True
# store the cookie for more than this session
session.permanent = True
if request.headers.get('User-Agent') == app.config['UPLOADER_USER_AGENT']:
return {'logged_in': True}, 200
else:
# flashes a message to the next request that can only be
# retrieved by get_flashed_messages()
flash('You are now logged in.', 'success')
return redirect(next_url or url_for('index'))
else:
if request.headers.get('User-Agent') == app.config['UPLOADER_USER_AGENT']:
return {'logged_in': False}, 403
else:
flash('Incorrect password.', 'danger')
if request.headers.get('User-Agent') == app.config['UPLOADER_USER_AGENT']:
return {'logged_in': False}, 403
else:
# template has action="{{ url_for('login', next=next_url) }}" in
# the form for entering the password
return render_template('login.html', next_url=next_url)
@app.route('/logout/', methods=['GET', 'POST'])
def logout():
if request.method == 'POST':
# clear the cookie
session.clear()
return redirect(url_for('login'))
return render_template('logout.html')
@app.route('/')
def index(q=None, t=None):
search_query = request.args.get('q') or q
tag_search_query = request.args.get('t') or t
search_title=None
if search_query:
query = Entry.search(search_query)
search_title = search_query
elif tag_search_query:
query = Tag.search(tag_search_query)
search_title = "Tag: " + tag_search_query
else:
query = Entry.public().order_by(Entry.timestamp.desc())
# object_list retrieves a paginated list of object in the query
# and displays them using the template provided
# by default, it paginates by 20 but this can be specified by a
# variable paginate_by
return object_list('index.html', query, search=search_title)
@app.route('/about/')
def about_me():
return render_template('about_me.html')
@app.route('/drafts/')
@login_required
def drafts():
query = Entry.drafts().order_by(Entry.timestamp.desc())
return object_list('index.html', query)
@app.route('/create/', methods=['GET', 'POST'])
@login_required
def create():
if request.method == 'POST':
if request.form.get('title') and request.form.get('content'):
entry = Entry.create(
title = request.form['title'],
content = request.form['content'],
published = request.form.get('published') or False)
flash('Entry created successfully.', 'success')
tags = [t.strip() for t in request.form['tags'].split(',')]
(new_tags, new_entrytags) = entry.add_tags(*tags)
flash(str(new_tags) + " new tags were created." )
flash(str(new_entrytags) + " new entry tag relationships were created." )
if entry.published:
return redirect(url_for('detail', slug=entry.slug))
else:
return redirect(url_for('edit', slug=entry.slug))
else:
flash('Title and content are required.', 'danger')
return render_template('create.html')
# Uploads a markdown file with headers from the blog_entry_uploader.py script
@app.route('/upload/', methods=['POST'])
@login_required
def upload():
try:
# ensure we are only uploading from the script
assert(request.headers.get('User-Agent') == app.config['UPLOADER_USER_AGENT'])
title = request.form.get('title')
# cannot seem to find a better way to convert the string boolean into boolean
published = True if request.form.get('published') == 'True' else False
# opens the file in the request as a temporary file
file = request.files['uploaded_file']
# INFO: https://docs.python.org/3/library/tempfile.html#tempfile-examples
file.stream.seek(0)
# do not know if we are currently working on ascii only or on unicode
content = file.stream.read().decode("utf-8")
entry = Entry.create(
title = title,
content = content,
published = published)
# code 201 -- requested resource has been created
return {'file_uploaded': True}, 201
except:
# code 400 -- bad request
return {'file_uploaded': False}, 400
@app.route('/tags/', methods=['GET', 'POST'])
def list_tags():
if request.method == 'POST':
tag = Tag.get(Tag.title == request.form.get('tag_title'))
tag.delete_instance()
return render_template('tags.html', tags=[t.title for t in Tag.select()])
# in a flask route, anything <> is a variable and is passed on to the
# function defining the route
@app.route('/<slug>/')
def detail(slug):
if session.get('logged_in'):
query = Entry.select()
else:
query = Entry.public()
# fairly self-defining but I'm not sure what the 404 object is (TODO)
entry = get_object_or_404(query, Entry.slug == slug)
return render_template('detail.html', entry=entry)
@app.route('/<slug>/edit/', methods=['GET', 'POST'])
@login_required
def edit(slug):
entry = get_object_or_404(Entry, Entry.slug == slug)
if request.method == 'POST':
if request.form.get('title') and request.form.get('content'):
entry.title = request.form['title']
entry.content = request.form['content']
entry.published = request.form.get('published') or False
entry.save()
tags = [t.strip() for t in request.form['tags'].split(',')]
(new_tags, new_entrytags) = entry.add_tags(*tags)
flash(str(new_tags) + " new tags were created." )
flash(str(new_entrytags) + " new entry tag relationships were created." )
flash('Entry saved successfully.', 'success')
if entry.published:
return redirect(url_for('detail', slug=entry.slug))
else:
return redirect(url_for('edit', slug=entry.slug))
else:
flash('Title and content are required.', 'danger')
return render_template('edit.html', entry=entry)
@app.route('/<slug>/delete/', methods=['POST'])
@login_required
def delete(slug):
entry = get_object_or_404(Entry, Entry.slug == slug)
try:
entry.delete_instance()
flash('Entry deleted.', 'success')
return redirect(url_for('index'))
except:
flash('Entry not deleted.', 'danger')
return redirect(url_for('edit', slug=slug))
##################
# App Initialization
##################
def main():
# create tables if they don't already exist
database.create_tables([Entry, FTSEntry, Tag, EntryTag])
app.run(debug=True, host='0.0.0.0')
# hooo
if __name__ == '__main__':
main()