tornado/demos/blog/blog.py

313 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Copyright 2009 Facebook
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import aiopg
import asyncio
import bcrypt
import markdown
import os.path
import psycopg2
import re
import tornado
import unicodedata
from tornado.options import define, options
define("port", default=8888, help="run on the given port", type=int)
define("db_host", default="127.0.0.1", help="blog database host")
define("db_port", default=5432, help="blog database port")
define("db_database", default="blog", help="blog database name")
define("db_user", default="blog", help="blog database user")
define("db_password", default="blog", help="blog database password")
class NoResultError(Exception):
pass
async def maybe_create_tables(db):
try:
with (await db.cursor()) as cur:
await cur.execute("SELECT COUNT(*) FROM entries LIMIT 1")
await cur.fetchone()
except psycopg2.ProgrammingError:
with open("schema.sql") as f:
schema = f.read()
with (await db.cursor()) as cur:
await cur.execute(schema)
class Application(tornado.web.Application):
def __init__(self, db):
self.db = db
handlers = [
(r"/", HomeHandler),
(r"/archive", ArchiveHandler),
(r"/feed", FeedHandler),
(r"/entry/([^/]+)", EntryHandler),
(r"/compose", ComposeHandler),
(r"/auth/create", AuthCreateHandler),
(r"/auth/login", AuthLoginHandler),
(r"/auth/logout", AuthLogoutHandler),
]
settings = dict(
blog_title="Tornado Blog",
template_path=os.path.join(os.path.dirname(__file__), "templates"),
static_path=os.path.join(os.path.dirname(__file__), "static"),
ui_modules={"Entry": EntryModule},
xsrf_cookies=True,
cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__",
login_url="/auth/login",
debug=True,
)
super().__init__(handlers, **settings)
class BaseHandler(tornado.web.RequestHandler):
def row_to_obj(self, row, cur):
"""Convert a SQL row to an object supporting dict and attribute access."""
obj = tornado.util.ObjectDict()
for val, desc in zip(row, cur.description):
obj[desc.name] = val
return obj
async def execute(self, stmt, *args):
"""Execute a SQL statement.
Must be called with ``await self.execute(...)``
"""
with (await self.application.db.cursor()) as cur:
await cur.execute(stmt, args)
async def query(self, stmt, *args):
"""Query for a list of results.
Typical usage::
results = await self.query(...)
Or::
for row in await self.query(...)
"""
with (await self.application.db.cursor()) as cur:
await cur.execute(stmt, args)
return [self.row_to_obj(row, cur) for row in await cur.fetchall()]
async def queryone(self, stmt, *args):
"""Query for exactly one result.
Raises NoResultError if there are no results, or ValueError if
there are more than one.
"""
results = await self.query(stmt, *args)
if len(results) == 0:
raise NoResultError()
elif len(results) > 1:
raise ValueError("Expected 1 result, got %d" % len(results))
return results[0]
async def prepare(self):
# get_current_user cannot be a coroutine, so set
# self.current_user in prepare instead.
user_id = self.get_signed_cookie("blogdemo_user")
if user_id:
self.current_user = await self.queryone(
"SELECT * FROM authors WHERE id = %s", int(user_id)
)
async def any_author_exists(self):
return bool(await self.query("SELECT * FROM authors LIMIT 1"))
class HomeHandler(BaseHandler):
async def get(self):
entries = await self.query(
"SELECT * FROM entries ORDER BY published DESC LIMIT 5"
)
if not entries:
self.redirect("/compose")
return
self.render("home.html", entries=entries)
class EntryHandler(BaseHandler):
async def get(self, slug):
entry = await self.queryone("SELECT * FROM entries WHERE slug = %s", slug)
if not entry:
raise tornado.web.HTTPError(404)
self.render("entry.html", entry=entry)
class ArchiveHandler(BaseHandler):
async def get(self):
entries = await self.query("SELECT * FROM entries ORDER BY published DESC")
self.render("archive.html", entries=entries)
class FeedHandler(BaseHandler):
async def get(self):
entries = await self.query(
"SELECT * FROM entries ORDER BY published DESC LIMIT 10"
)
self.set_header("Content-Type", "application/atom+xml")
self.render("feed.xml", entries=entries)
class ComposeHandler(BaseHandler):
@tornado.web.authenticated
async def get(self):
id = self.get_argument("id", None)
entry = None
if id:
entry = await self.queryone("SELECT * FROM entries WHERE id = %s", int(id))
self.render("compose.html", entry=entry)
@tornado.web.authenticated
async def post(self):
id = self.get_argument("id", None)
title = self.get_argument("title")
text = self.get_argument("markdown")
html = markdown.markdown(text)
if id:
try:
entry = await self.queryone(
"SELECT * FROM entries WHERE id = %s", int(id)
)
except NoResultError:
raise tornado.web.HTTPError(404)
slug = entry.slug
await self.execute(
"UPDATE entries SET title = %s, markdown = %s, html = %s "
"WHERE id = %s",
title,
text,
html,
int(id),
)
else:
slug = unicodedata.normalize("NFKD", title)
slug = re.sub(r"[^\w]+", " ", slug)
slug = "-".join(slug.lower().strip().split())
slug = slug.encode("ascii", "ignore").decode("ascii")
if not slug:
slug = "entry"
while True:
e = await self.query("SELECT * FROM entries WHERE slug = %s", slug)
if not e:
break
slug += "-2"
await self.execute(
"INSERT INTO entries (author_id,title,slug,markdown,html,published,updated)"
"VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)",
self.current_user.id,
title,
slug,
text,
html,
)
self.redirect("/entry/" + slug)
class AuthCreateHandler(BaseHandler):
def get(self):
self.render("create_author.html")
async def post(self):
if await self.any_author_exists():
raise tornado.web.HTTPError(400, "author already created")
hashed_password = await tornado.ioloop.IOLoop.current().run_in_executor(
None,
bcrypt.hashpw,
tornado.escape.utf8(self.get_argument("password")),
bcrypt.gensalt(),
)
author = await self.queryone(
"INSERT INTO authors (email, name, hashed_password) "
"VALUES (%s, %s, %s) RETURNING id",
self.get_argument("email"),
self.get_argument("name"),
tornado.escape.to_unicode(hashed_password),
)
self.set_signed_cookie("blogdemo_user", str(author.id))
self.redirect(self.get_argument("next", "/"))
class AuthLoginHandler(BaseHandler):
async def get(self):
# If there are no authors, redirect to the account creation page.
if not await self.any_author_exists():
self.redirect("/auth/create")
else:
self.render("login.html", error=None)
async def post(self):
try:
author = await self.queryone(
"SELECT * FROM authors WHERE email = %s", self.get_argument("email")
)
except NoResultError:
self.render("login.html", error="email not found")
return
password_equal = await tornado.ioloop.IOLoop.current().run_in_executor(
None,
bcrypt.checkpw,
tornado.escape.utf8(self.get_argument("password")),
tornado.escape.utf8(author.hashed_password),
)
if password_equal:
self.set_signed_cookie("blogdemo_user", str(author.id))
self.redirect(self.get_argument("next", "/"))
else:
self.render("login.html", error="incorrect password")
class AuthLogoutHandler(BaseHandler):
def get(self):
self.clear_cookie("blogdemo_user")
self.redirect(self.get_argument("next", "/"))
class EntryModule(tornado.web.UIModule):
def render(self, entry):
return self.render_string("modules/entry.html", entry=entry)
async def main():
tornado.options.parse_command_line()
# Create the global connection pool.
async with aiopg.create_pool(
host=options.db_host,
port=options.db_port,
user=options.db_user,
password=options.db_password,
dbname=options.db_database,
) as db:
await maybe_create_tables(db)
app = Application(db)
app.listen(options.port)
# In this demo the server will simply run until interrupted
# with Ctrl-C, but if you want to shut down more gracefully,
# call shutdown_event.set().
shutdown_event = tornado.locks.Event()
await shutdown_event.wait()
if __name__ == "__main__":
asyncio.run(main())