Repos / pytaku / c94ffc8188
commit c94ffc818876ed3e534a17435f59400a511ed6c4
Author: Bùi Thành Nhân <hi@imnhan.com>
Date: Tue Aug 4 23:22:18 2020 +0700
implement register/login
diff --git a/poetry.lock b/poetry.lock
index da61531..76c4725 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,3 +1,20 @@
+[[package]]
+name = "argon2-cffi"
+version = "20.1.0"
+description = "The secure Argon2 password hashing algorithm."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+dev = ["coverage (>=5.0.2)", "hypothesis", "pytest", "sphinx", "wheel", "pre-commit"]
+docs = ["sphinx"]
+tests = ["coverage (>=5.0.2)", "hypothesis", "pytest"]
+
+[package.dependencies]
+cffi = ">=1.0.0"
+six = "*"
+
[[package]]
name = "certifi"
version = "2020.6.20"
@@ -6,6 +23,17 @@ category = "main"
optional = false
python-versions = "*"
+[[package]]
+name = "cffi"
+version = "1.14.1"
+description = "Foreign Function Interface for Python calling C code."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pycparser = "*"
+
[[package]]
name = "chardet"
version = "3.0.4"
@@ -109,6 +137,14 @@ category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
+[[package]]
+name = "pycparser"
+version = "2.20"
+description = "C parser in Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
[[package]]
name = "requests"
version = "2.24.0"
@@ -127,6 +163,14 @@ chardet = ">=3.0.2,<4"
idna = ">=2.5,<3"
urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26"
+[[package]]
+name = "six"
+version = "1.15.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
[[package]]
name = "urllib3"
version = "1.25.10"
@@ -155,13 +199,61 @@ watchdog = ["watchdog"]
[metadata]
lock-version = "1.0"
python-versions = "^3.7"
-content-hash = "4c5557f7eb25c9e75ce9983a3ba5cabcddb59a6d26ff11185732aa13f954a326"
+content-hash = "5efa8d3aa125259ad269866dce833dcfd765044a16533d68c65897d73d9eaff4"
[metadata.files]
+argon2-cffi = [
+ {file = "argon2-cffi-20.1.0.tar.gz", hash = "sha256:d8029b2d3e4b4cea770e9e5a0104dd8fa185c1724a0f01528ae4826a6d25f97d"},
+ {file = "argon2_cffi-20.1.0-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:6ea92c980586931a816d61e4faf6c192b4abce89aa767ff6581e6ddc985ed003"},
+ {file = "argon2_cffi-20.1.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:05a8ac07c7026542377e38389638a8a1e9b78f1cd8439cd7493b39f08dd75fbf"},
+ {file = "argon2_cffi-20.1.0-cp27-cp27m-win32.whl", hash = "sha256:0bf066bc049332489bb2d75f69216416329d9dc65deee127152caeb16e5ce7d5"},
+ {file = "argon2_cffi-20.1.0-cp27-cp27m-win_amd64.whl", hash = "sha256:57358570592c46c420300ec94f2ff3b32cbccd10d38bdc12dc6979c4a8484fbc"},
+ {file = "argon2_cffi-20.1.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:7d455c802727710e9dfa69b74ccaab04568386ca17b0ad36350b622cd34606fe"},
+ {file = "argon2_cffi-20.1.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:b160416adc0f012fb1f12588a5e6954889510f82f698e23ed4f4fa57f12a0647"},
+ {file = "argon2_cffi-20.1.0-cp35-cp35m-win32.whl", hash = "sha256:9bee3212ba4f560af397b6d7146848c32a800652301843df06b9e8f68f0f7361"},
+ {file = "argon2_cffi-20.1.0-cp35-cp35m-win_amd64.whl", hash = "sha256:392c3c2ef91d12da510cfb6f9bae52512a4552573a9e27600bdb800e05905d2b"},
+ {file = "argon2_cffi-20.1.0-cp36-cp36m-win32.whl", hash = "sha256:ba7209b608945b889457f949cc04c8e762bed4fe3fec88ae9a6b7765ae82e496"},
+ {file = "argon2_cffi-20.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:da7f0445b71db6d3a72462e04f36544b0de871289b0bc8a7cc87c0f5ec7079fa"},
+ {file = "argon2_cffi-20.1.0-cp37-abi3-macosx_10_6_intel.whl", hash = "sha256:cc0e028b209a5483b6846053d5fd7165f460a1f14774d79e632e75e7ae64b82b"},
+ {file = "argon2_cffi-20.1.0-cp37-cp37m-win32.whl", hash = "sha256:18dee20e25e4be86680b178b35ccfc5d495ebd5792cd00781548d50880fee5c5"},
+ {file = "argon2_cffi-20.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:6678bb047373f52bcff02db8afab0d2a77d83bde61cfecea7c5c62e2335cb203"},
+ {file = "argon2_cffi-20.1.0-cp38-cp38-win32.whl", hash = "sha256:77e909cc756ef81d6abb60524d259d959bab384832f0c651ed7dcb6e5ccdbb78"},
+ {file = "argon2_cffi-20.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:9dfd5197852530294ecb5795c97a823839258dfd5eb9420233c7cfedec2058f2"},
+]
certifi = [
{file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"},
{file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"},
]
+cffi = [
+ {file = "cffi-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2"},
+ {file = "cffi-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8"},
+ {file = "cffi-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1"},
+ {file = "cffi-1.14.1-cp27-cp27m-win32.whl", hash = "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9"},
+ {file = "cffi-1.14.1-cp27-cp27m-win_amd64.whl", hash = "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1"},
+ {file = "cffi-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168"},
+ {file = "cffi-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf"},
+ {file = "cffi-1.14.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e"},
+ {file = "cffi-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849"},
+ {file = "cffi-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c"},
+ {file = "cffi-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa"},
+ {file = "cffi-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948"},
+ {file = "cffi-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f"},
+ {file = "cffi-1.14.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3"},
+ {file = "cffi-1.14.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc"},
+ {file = "cffi-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2"},
+ {file = "cffi-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022"},
+ {file = "cffi-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9"},
+ {file = "cffi-1.14.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0"},
+ {file = "cffi-1.14.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33"},
+ {file = "cffi-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792"},
+ {file = "cffi-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96"},
+ {file = "cffi-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc"},
+ {file = "cffi-1.14.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939"},
+ {file = "cffi-1.14.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe"},
+ {file = "cffi-1.14.1-cp38-cp38-win32.whl", hash = "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995"},
+ {file = "cffi-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90"},
+ {file = "cffi-1.14.1.tar.gz", hash = "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f"},
+]
chardet = [
{file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
{file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
@@ -229,10 +321,18 @@ markupsafe = [
{file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
{file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
]
+pycparser = [
+ {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
+ {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
+]
requests = [
{file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"},
{file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"},
]
+six = [
+ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
+ {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
+]
urllib3 = [
{file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"},
{file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"},
diff --git a/pyproject.toml b/pyproject.toml
index ed83127..0ec75e8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,7 @@ flask = "^1.1.2"
gunicorn = "^20.0.4"
requests = "^2.24.0"
goodconf = "^1.0.0"
+argon2-cffi = "^20.1.0"
[tool.poetry.dev-dependencies]
diff --git a/src/pytaku/conf.py b/src/pytaku/conf.py
index dc1786e..1004b16 100644
--- a/src/pytaku/conf.py
+++ b/src/pytaku/conf.py
@@ -1,3 +1,5 @@
+from secrets import token_urlsafe
+
from goodconf import GoodConf, Value
@@ -5,5 +7,7 @@ class Config(GoodConf):
MANGADEX_USERNAME = Value()
MANGADEX_PASSWORD = Value()
+ FLASK_SECRET_KEY = Value(initial=lambda: token_urlsafe(50))
+
config = Config(default_files=["pytaku.conf.json"], load=True)
diff --git a/src/pytaku/database/migrations/latest_schema.sql b/src/pytaku/database/migrations/latest_schema.sql
index 92857ab..cdff562 100644
--- a/src/pytaku/database/migrations/latest_schema.sql
+++ b/src/pytaku/database/migrations/latest_schema.sql
@@ -28,3 +28,19 @@ CREATE TABLE chapter (
unique(id, title_id, site),
unique(num_major, num_minor, title_id)
);
+CREATE TABLE user (
+ id integer primary key,
+ username text unique,
+ password text,
+ created_at text default (datetime('now'))
+);
+CREATE TABLE follow (
+ user_id integer not null,
+ title_id text not null,
+ site text not null,
+ created_at text default (datetime('now')),
+
+ foreign key (title_id, site) references title (id, site),
+ foreign key (user_id) references user (id),
+ unique(user_id, title_id, site)
+);
diff --git a/src/pytaku/database/migrations/m0003.sql b/src/pytaku/database/migrations/m0003.sql
new file mode 100644
index 0000000..1cd8c9b
--- /dev/null
+++ b/src/pytaku/database/migrations/m0003.sql
@@ -0,0 +1,18 @@
+create table user (
+ id integer primary key,
+ username text unique,
+ password text,
+ created_at text default (datetime('now'))
+);
+
+
+create table follow (
+ user_id integer not null,
+ title_id text not null,
+ site text not null,
+ created_at text default (datetime('now')),
+
+ foreign key (title_id, site) references title (id, site),
+ foreign key (user_id) references user (id),
+ unique(user_id, title_id, site)
+);
diff --git a/src/pytaku/decorators.py b/src/pytaku/decorators.py
new file mode 100644
index 0000000..ff5f4e2
--- /dev/null
+++ b/src/pytaku/decorators.py
@@ -0,0 +1,13 @@
+from functools import wraps
+
+from flask import redirect, request, session, url_for
+
+
+def require_login(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ if session.get("user") is None:
+ return redirect(url_for("auth_view", next=request.url))
+ return f(*args, **kwargs)
+
+ return decorated_function
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index 0a23275..bbfb064 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -1,21 +1,36 @@
import base64
import re
+from datetime import timedelta
import requests
-from flask import Flask, make_response, render_template, request, url_for
+from flask import (
+ Flask,
+ make_response,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
from mangoapi import get_chapter, get_title, search_title
from . import mangadex
+from .conf import config
from .persistence import (
get_prev_next_chapters,
load_chapter,
load_title,
+ register_user,
save_chapter,
save_title,
+ verify_username_password,
)
app = Flask(__name__)
+app.config.update(
+ SECRET_KEY=config.FLASK_SECRET_KEY, PERMANENT_SESSION_LIFETIME=timedelta(days=365),
+)
@app.route("/")
@@ -23,6 +38,93 @@ def home_view():
return render_template("home.html")
+@app.route("/logout", methods=["POST"])
+def logout_view():
+ session.pop("user")
+ return redirect("/")
+
+
+@app.route("/auth", methods=["GET", "POST"])
+def auth_view():
+ if session.get("user"):
+ return redirect(url_for("home_view"))
+
+ if request.method == "POST":
+
+ if request.form["action"] == "register":
+ username = request.form["username"].strip()
+ password = request.form["password"]
+ confirm_password = request.form["confirm-password"]
+ message = None
+ if password != confirm_password:
+ message = "Password confirmation didn't match."
+ status_code = 400
+ elif not (username and password and confirm_password):
+ message = "Empty field(s) spotted. Protip: spaces don't count."
+ status_code = 400
+ elif (
+ len(username) < 2
+ or len(username) > 15
+ or len(password) < 5
+ or len(password) > 50
+ ):
+ message = "Invalid username/password length. Username length should be 2~15, password 5~50."
+ status_code = 400
+ else: # success!
+ err = register_user(username, password)
+ if err:
+ message = err
+ status_code = 400
+ else:
+ username = ""
+ password = ""
+ confirm_password = ""
+ message = "Registration successful! You can login now."
+ status_code = 200
+ return (
+ render_template(
+ "auth.html",
+ register_username=username,
+ register_password=password,
+ register_confirm_password=confirm_password,
+ register_message=message,
+ register_has_error=status_code != 200,
+ ),
+ status_code,
+ )
+
+ else: # action == 'login'
+ username = request.form["username"].strip()
+ password = request.form["password"]
+ remember = request.form.get("remember") == "on"
+ if not (username and password):
+ message = "Empty field(s) spotted. Protip: spaces don't count."
+ status_code = 400
+ elif not verify_username_password(username, password):
+ message = "Wrong username/password combination."
+ status_code = 400
+ else: # success!
+ resp = redirect(request.args.get("next", url_for("home_view")))
+ session.permanent = remember
+ session["user"] = {"username": username}
+ return resp
+
+ return (
+ render_template(
+ "auth.html",
+ login_username=username,
+ login_password=password,
+ login_remember=remember,
+ login_message=message,
+ login_has_error=status_code != 200,
+ ),
+ status_code,
+ )
+
+ # Just a plain ol' GET request:
+ return render_template("auth.html")
+
+
@app.route("/title/mangadex/<title_id>")
def title_view(title_id):
title = load_title(title_id)
diff --git a/src/pytaku/persistence.py b/src/pytaku/persistence.py
index e12c15e..61bfde4 100644
--- a/src/pytaku/persistence.py
+++ b/src/pytaku/persistence.py
@@ -1,5 +1,8 @@
import json
+import apsw
+import argon2
+
from .database.common import get_conn
@@ -161,3 +164,38 @@ def get_prev_next_chapters(title, chapter):
prev_chapter = chapters[i + 1]
return prev_chapter, next_chapter
+
+
+def register_user(username, password):
+ hasher = argon2.PasswordHasher()
+ hashed_password = hasher.hash(password)
+ try:
+ get_conn().cursor().execute(
+ "INSERT INTO user (username, password) VALUES (?, ?);",
+ (username, hashed_password),
+ )
+ return None
+ except apsw.ConstraintError as e:
+ if "UNIQUE" in str(e):
+ return "Username already exists."
+ raise
+
+
+def verify_username_password(username, password):
+ data = list(
+ get_conn()
+ .cursor()
+ .execute("SELECT password FROM user WHERE username = ?;", (username,))
+ )
+ if len(data) != 1:
+ print(f"User {username} doesn't exist.")
+ return False
+
+ hasher = argon2.PasswordHasher()
+ hash = data[0][0]
+ try:
+ hasher.verify(hash, password)
+ return True
+ except argon2.exceptions.VerifyMismatchError:
+ print(f"User {username} exists but password doesn't match.")
+ return False
diff --git a/src/pytaku/static/base.css b/src/pytaku/static/base.css
index e366545..6b9f446 100644
--- a/src/pytaku/static/base.css
+++ b/src/pytaku/static/base.css
@@ -131,6 +131,7 @@ .links {
display: inline-flex;
justify-content: center;
align-items: center;
+ color: white;
}
.links > * {
margin-right: 1rem;
diff --git a/src/pytaku/static/icons/log-in.svg b/src/pytaku/static/icons/log-in.svg
new file mode 100644
index 0000000..7bfc566
--- /dev/null
+++ b/src/pytaku/static/icons/log-in.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-in"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg>
\ No newline at end of file
diff --git a/src/pytaku/static/icons/log-out.svg b/src/pytaku/static/icons/log-out.svg
new file mode 100644
index 0000000..628a96d
--- /dev/null
+++ b/src/pytaku/static/icons/log-out.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
\ No newline at end of file
diff --git a/src/pytaku/templates/auth.html b/src/pytaku/templates/auth.html
new file mode 100644
index 0000000..96b042a
--- /dev/null
+++ b/src/pytaku/templates/auth.html
@@ -0,0 +1,67 @@
+{% extends 'base.html' %}
+
+{% block title %}
+Login / Register
+{% endblock %}
+
+{% block head %}
+<style>
+.content {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ margin: auto;
+ max-width: 900px;
+ padding: 0;
+}
+
+.content > form {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ margin: 0 1rem;
+}
+
+.content input,
+.content button {
+ margin: .5rem 0;
+}
+
+.success {
+ color: inherit;
+}
+.error {
+ color: red;
+}
+
+</style>
+{% endblock %}
+
+{% block content %}
+<form method="POST">
+ <h1>Login</h1>
+ <input type="hidden" name="action" value="login">
+ <input name="username" placeholder="Username" required value="{{ login_username }}">
+ <input name="password" type="password" placeholder="Password" required value="{{ login_password }}">
+ <label for="remember">
+ <input name="remember" id="remember" type="checkbox" {% if login_remember %}checked{% endif %}>
+ Remember me
+ </label>
+ <button class="green button" type="submit">Login</button>
+ <p class="{{ 'error' if login_has_error else 'success'}}">
+ {{ login_message }}
+ </p>
+</form>
+
+<form method="POST">
+ <h1>Register</h1>
+ <input type="hidden" name="action" value="register">
+ <input name="username" placeholder="Username (2~15 chars)" required value="{{ register_username }}">
+ <input name="password" type="password" placeholder="Password (5~50 chars)" required value="{{ register_password }}">
+ <input name="confirm-password" type="password" placeholder="Confirm password" required value="{{ register_confirm_password }}">
+ <button class="blue button" type="submit">Register</button>
+ <p class="{{ 'error' if register_has_error else 'success'}}">
+ {{ register_message }}
+ </p>
+</form>
+{% endblock %}
diff --git a/src/pytaku/templates/base.html b/src/pytaku/templates/base.html
index 022fefc..6fef79e 100644
--- a/src/pytaku/templates/base.html
+++ b/src/pytaku/templates/base.html
@@ -47,14 +47,17 @@
</button>
</form>
<span class="links">
- <a href="/bookmarks">
- <img src="{{ url_for('static', filename='icons/bookmark.svg')}}" alt="bookmarks" />
- <span>Bookmarks</span>
- </a>
- <a href="/account">
- <img src="{{ url_for('static', filename='icons/user.svg')}}" alt="account" />
- <span>Account</span>
+ {% if session.get('user') %}
+ <span>Hi there <strong>{{ session.user['username'] }}</strong>!</span>
+ <form method="POST" action="{{ url_for('logout_view') }}">
+ {{ ibutton(left_icon='log-out', text='Logout') }}
+ </form>
+ {% else %}
+ <a href="/auth">
+ <img src="{{ url_for('static', filename='icons/log-in.svg')}}" alt="login/register" />
+ <span>Login/Register</span>
</a>
+ {% endif %}
</span>
</ul>
</nav>