JsonDB, In'tl setup, user login and admin pages
This commit is contained in:
parent
f86d1df276
commit
80c20ec87b
|
@ -7,3 +7,11 @@ Rophako is [Azulian](http://www.kirsle.net/wizards/translator.html) for
|
|||
"website." Pronounce it however you like. I pronounce it "roe-fa-koe."
|
||||
|
||||
This project is under heavy construction.
|
||||
|
||||
# Installation
|
||||
|
||||
`pip install -r requirements.txt`
|
||||
|
||||
These may need to be installed for the dependencies to build:
|
||||
|
||||
**Fedora:** `libffi-devel`
|
||||
|
|
|
@ -22,8 +22,12 @@ SITE_NAME = "example.com"
|
|||
# Do NOT use that one. It was just an example! Make your own.
|
||||
SECRET_KEY = 'for the love of Arceus, change this key!'
|
||||
|
||||
# Password strength: number of iterations for bcrypt to hash passwords.
|
||||
BCRYPT_ITERATIONS = 12
|
||||
|
||||
# Rophako uses a flat file JSON database system, and the Redis caching server
|
||||
# sits between Ropahko and the filesystem.
|
||||
DB_ROOT = "db"
|
||||
REDIS_HOST = "localhost"
|
||||
REDIS_PORT = 6379
|
||||
REDIS_DB = 0
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
flask
|
||||
redis
|
||||
redis
|
||||
bcrypt
|
||||
|
|
|
@ -1,17 +1,23 @@
|
|||
__version__ = '0.01'
|
||||
|
||||
from flask import Flask, g, request, session, render_template, send_file
|
||||
from flask import Flask, g, request, session, render_template, send_file, abort
|
||||
import jinja2
|
||||
import os.path
|
||||
import time
|
||||
|
||||
import config
|
||||
import rophako.utils
|
||||
|
||||
app = Flask(__name__)
|
||||
app = Flask(__name__,
|
||||
static_url_path="/.static",
|
||||
)
|
||||
app.DEBUG = config.DEBUG
|
||||
app.SECRET_KEY = config.SECRET_KEY
|
||||
app.secret_key = config.SECRET_KEY
|
||||
|
||||
# Load all the blueprints!
|
||||
from rophako.modules.admin import mod as AdminModule
|
||||
from rophako.modules.account import mod as AccountModule
|
||||
app.register_blueprint(AdminModule)
|
||||
app.register_blueprint(AccountModule)
|
||||
|
||||
# Custom Jinja handler to support custom- and default-template folders for
|
||||
|
@ -21,25 +27,65 @@ app.jinja_loader = jinja2.ChoiceLoader([
|
|||
jinja2.FileSystemLoader("rophako/www"), # Default
|
||||
])
|
||||
|
||||
app.jinja_env.globals["csrf_token"] = rophako.utils.generate_csrf_token
|
||||
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
"""Called before all requests. Initialize global template variables."""
|
||||
|
||||
# CSRF protection.
|
||||
if request.method == "POST":
|
||||
token = session.pop("_csrf", None)
|
||||
if not token or str(token) != str(request.form.get("token")):
|
||||
abort(403)
|
||||
|
||||
# Default template vars.
|
||||
g.info = {
|
||||
"time": time.time(),
|
||||
"app": {
|
||||
"name": "Rophako",
|
||||
"version": __version__,
|
||||
"author": "Noah Petherbridge",
|
||||
},
|
||||
"uri": request.path.split("/")[1:],
|
||||
"session": {
|
||||
"login": False, # Not logged in, until proven otherwise.
|
||||
"username": "guest",
|
||||
"uid": 0,
|
||||
"name": "Guest",
|
||||
"role": "user",
|
||||
}
|
||||
}
|
||||
|
||||
# Default session vars.
|
||||
if not "login" in session:
|
||||
session.update(g.info["session"])
|
||||
|
||||
# Refresh their login status from the DB.
|
||||
if session["login"]:
|
||||
import rophako.model.user as User
|
||||
if not User.exists(uid=session["uid"]):
|
||||
# Weird! Log them out.
|
||||
from rophako.modules.account import logout
|
||||
logout()
|
||||
return
|
||||
|
||||
db = User.get_user(uid=session["uid"])
|
||||
session["username"] = db["username"]
|
||||
session["name"] = db["name"]
|
||||
session["role"] = db["role"]
|
||||
|
||||
# Copy session params into g.info. The only people who should touch the
|
||||
# session are the login/out pages.
|
||||
for key in session:
|
||||
g.info["session"][key] = session[key]
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def after_request():
|
||||
"""Called just before render_template. Inject g.info into the template vars."""
|
||||
g.info["time_elapsed"] = "%.03f" % (time.time() - g.info["time"])
|
||||
return g.info
|
||||
|
||||
|
||||
|
@ -51,8 +97,6 @@ def catchall(path):
|
|||
# Search for this file.
|
||||
for root in ["site/www", "rophako/www"]:
|
||||
abspath = os.path.abspath("{}/{}".format(root, path))
|
||||
print abspath
|
||||
print abspath + ".html"
|
||||
if os.path.isfile(abspath):
|
||||
return send_file(abspath)
|
||||
elif not "." in path and os.path.isfile(abspath + ".html"):
|
||||
|
@ -72,6 +116,7 @@ def not_found(error):
|
|||
print "NOT FOUND"
|
||||
return render_template('errors/404.html', **g.info), 404
|
||||
|
||||
|
||||
# Domain specific endpoints.
|
||||
if config.SITE_NAME == "kirsle.net":
|
||||
import rophako.modules.kirsle_legacy
|
209
rophako/jsondb.py
Normal file
209
rophako/jsondb.py
Normal file
|
@ -0,0 +1,209 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""JSON flat file database system."""
|
||||
|
||||
import codecs
|
||||
import os
|
||||
import os.path
|
||||
import glob
|
||||
import re
|
||||
from fcntl import flock, LOCK_EX, LOCK_SH, LOCK_UN
|
||||
import redis
|
||||
import json
|
||||
import time
|
||||
|
||||
import config
|
||||
from rophako.log import logger
|
||||
|
||||
redis_client = None
|
||||
cache_lifetime = 60*60 # 1 hour
|
||||
|
||||
|
||||
def get(document):
|
||||
"""Get a specific document from the DB."""
|
||||
logger.debug("JsonDB: GET {}".format(document))
|
||||
|
||||
# Exists?
|
||||
if not exists(document):
|
||||
logger.debug("Requested document doesn't exist")
|
||||
return None
|
||||
|
||||
path = mkpath(document)
|
||||
stat = os.stat(path)
|
||||
|
||||
# Do we have it cached?
|
||||
data = get_cache(document)
|
||||
if data:
|
||||
# Check if the cache is fresh.
|
||||
if stat.st_mtime > get_cache(document+"_mtime"):
|
||||
del_cache(document)
|
||||
del_cache(document+"_mtime")
|
||||
else:
|
||||
return data
|
||||
|
||||
# Get the JSON data.
|
||||
data = read_json(path)
|
||||
|
||||
# Cache and return it.
|
||||
set_cache(document, data, expires=cache_lifetime)
|
||||
set_cache(document+"_mtime", stat.st_mtime, expires=cache_lifetime)
|
||||
return data
|
||||
|
||||
|
||||
def commit(document, data):
|
||||
"""Insert/update a document in the DB."""
|
||||
|
||||
# Need to create the file?
|
||||
path = mkpath(document)
|
||||
if not os.path.isfile(path):
|
||||
parts = path.split("/")
|
||||
parts.pop() # Remove the file part
|
||||
directory = list()
|
||||
|
||||
# Create all the folders.
|
||||
for part in parts:
|
||||
directory.append(part)
|
||||
segment = "/".join(directory)
|
||||
if len(segment) > 0 and not os.path.isdir(segment):
|
||||
logger.debug("JsonDB: mkdir {}".format(segment))
|
||||
os.mkdir(segment, 0755)
|
||||
|
||||
# Update the cached document.
|
||||
set_cache(document, data, expires=cache_lifetime)
|
||||
set_cache(document+"_mtime", time.time(), expires=cache_lifetime)
|
||||
|
||||
# Write the JSON.
|
||||
write_json(path, data)
|
||||
|
||||
|
||||
def delete(document):
|
||||
"""Delete a document from the DB."""
|
||||
path = mkpath(document)
|
||||
if os.path.isfile(path):
|
||||
logger.info("Delete DB document: {}".format(path))
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def exists(document):
|
||||
"""Query whether a document exists."""
|
||||
path = mkpath(document)
|
||||
return os.path.isfile(path)
|
||||
|
||||
|
||||
def list_docs(path):
|
||||
"""List all the documents at the path."""
|
||||
path = mkpath("{}/*".format(path))
|
||||
docs = list()
|
||||
|
||||
for item in glob.glob(path):
|
||||
name = re.sub(r'\.json$', '', item)
|
||||
name = name.split("/")[-1]
|
||||
docs.append(name)
|
||||
|
||||
return docs
|
||||
|
||||
|
||||
def mkpath(document):
|
||||
"""Turn a DB path into a JSON file path."""
|
||||
if document.endswith(".json"):
|
||||
# Let's not do that.
|
||||
raise Exception("mkpath: document path already includes .json extension!")
|
||||
return "{}/{}.json".format(config.DB_ROOT, str(document))
|
||||
|
||||
|
||||
def read_json(path):
|
||||
"""Slurp, decode and return the data from a JSON document."""
|
||||
path = str(path)
|
||||
if not os.path.isfile(path):
|
||||
raise Exception("Can't read JSON file {}: file not found!".format(path))
|
||||
|
||||
# Open and lock the file.
|
||||
fh = codecs.open(path, 'r', 'utf-8')
|
||||
flock(fh, LOCK_SH)
|
||||
text = fh.read()
|
||||
flock(fh, LOCK_UN)
|
||||
fh.close()
|
||||
|
||||
# Decode.
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except:
|
||||
logger.error("Couldn't decode JSON data from {}".format(path))
|
||||
data = None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def write_json(path, data):
|
||||
"""Write a JSON document."""
|
||||
path = str(path)
|
||||
|
||||
logger.debug("JsonDB: WRITE > {}".format(path))
|
||||
|
||||
# Open and lock the file.
|
||||
fh = None
|
||||
if os.path.isfile(path):
|
||||
fh = codecs.open(path, 'r+', 'utf-8')
|
||||
else:
|
||||
fh = codecs.open(path, 'w', 'utf-8')
|
||||
flock(fh, LOCK_EX)
|
||||
|
||||
# Write it.
|
||||
fh.truncate(0)
|
||||
fh.write(json.dumps(data, sort_keys=True, indent=4, separators=(',', ': ')))
|
||||
|
||||
# Unlock and close.
|
||||
flock(fh, LOCK_UN)
|
||||
fh.close()
|
||||
|
||||
|
||||
############################################################################
|
||||
# Redis Caching Functions #
|
||||
############################################################################
|
||||
|
||||
def get_redis():
|
||||
"""Connect to Redis or return the existing connection."""
|
||||
global redis_client
|
||||
if not redis_client:
|
||||
redis_client = redis.StrictRedis(
|
||||
host = config.REDIS_HOST,
|
||||
port = config.REDIS_PORT,
|
||||
db = config.REDIS_DB,
|
||||
)
|
||||
return redis_client
|
||||
|
||||
|
||||
def set_cache(key, value, expires=None):
|
||||
"""Set a key in the Redis cache."""
|
||||
key = config.REDIS_PREFIX + key
|
||||
try:
|
||||
client = get_redis()
|
||||
client.set(key, json.dumps(value))
|
||||
|
||||
# Expiration date?
|
||||
if expires:
|
||||
client.expire(key, expires)
|
||||
except:
|
||||
logger.error("Redis exception: couldn't set_cache {}".format(key))
|
||||
|
||||
|
||||
def get_cache(key):
|
||||
"""Get a cached item."""
|
||||
key = config.REDIS_PREFIX + key
|
||||
value = None
|
||||
try:
|
||||
client = get_redis()
|
||||
value = client.get(key)
|
||||
if value:
|
||||
value = json.loads(value)
|
||||
except:
|
||||
logger.warning("Redis exception: couldn't get_cache {}".format(key))
|
||||
value = None
|
||||
return value
|
||||
|
||||
|
||||
def del_cache(key):
|
||||
"""Delete a cached item."""
|
||||
key = config.REDIS_PREFIX + key
|
||||
client = get_redis()
|
||||
client.delete(key)
|
33
rophako/log.py
Normal file
33
rophako/log.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Debug and logging functions."""
|
||||
|
||||
from flask import g, request
|
||||
import logging
|
||||
|
||||
import config
|
||||
|
||||
class LogHandler(logging.Handler):
|
||||
"""A custom logging handler."""
|
||||
|
||||
def emit(self, record):
|
||||
# The initial log line, which has the $prefix$ in it.
|
||||
line = self.format(record)
|
||||
|
||||
# Is the user logged in?
|
||||
name = "-nobody-"
|
||||
|
||||
line = line.replace('$prefix$', '')
|
||||
print line
|
||||
|
||||
# Set up the logger.
|
||||
logger = logging.getLogger("rophako")
|
||||
handler = LogHandler()
|
||||
handler.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] $prefix$%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
# Log level.
|
||||
if config.DEBUG:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.INFO)
|
3
rophako/model/__init__.py
Normal file
3
rophako/model/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Database models."""
|
162
rophako/model/user.py
Normal file
162
rophako/model/user.py
Normal file
|
@ -0,0 +1,162 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""User account models."""
|
||||
|
||||
import bcrypt
|
||||
import time
|
||||
|
||||
import config
|
||||
import rophako.jsondb as JsonDB
|
||||
from rophako.log import logger
|
||||
|
||||
def create(username, password, name=None, uid=None, role="user"):
|
||||
"""Create a new user account.
|
||||
|
||||
Returns the user ID number assigned to this user."""
|
||||
# Name defaults to username.
|
||||
if name is None:
|
||||
name = username
|
||||
|
||||
username = username.lower()
|
||||
|
||||
# Provided with a user ID?
|
||||
if uid is not None:
|
||||
# See if it's available.
|
||||
if exists(uid=uid):
|
||||
logger.warning("Wanted to use UID {} for user {} but it wasn't available.".format(uid, username))
|
||||
uid = None
|
||||
|
||||
# Need to generate a UID?
|
||||
if uid is None:
|
||||
uid = get_next_uid()
|
||||
|
||||
uid = int(uid)
|
||||
|
||||
# Username musn't exist.
|
||||
if exists(username):
|
||||
# The front-end shouldn't let this happen.
|
||||
raise Exception("Can't create username {}: already exists!".format(username))
|
||||
|
||||
# Crypt their password.
|
||||
hashedpass = hash_password(password)
|
||||
|
||||
logger.info("Create user {} with username {}".format(uid, username))
|
||||
|
||||
# Create the user file.
|
||||
JsonDB.commit("users/by-id/{}".format(uid), dict(
|
||||
uid=uid,
|
||||
username=username,
|
||||
name=name,
|
||||
role=role,
|
||||
password=hashedpass,
|
||||
created=time.time(),
|
||||
))
|
||||
|
||||
# And their username to ID map.
|
||||
JsonDB.commit("users/by-name/{}".format(username), dict(
|
||||
uid=uid,
|
||||
))
|
||||
|
||||
return uid
|
||||
|
||||
|
||||
def update_user(uid, data):
|
||||
"""Update the user's data."""
|
||||
if not exists(uid=uid):
|
||||
raise Exception("Can't update user {}: doesn't exist!".format(uid))
|
||||
|
||||
db = get_user(uid=uid)
|
||||
|
||||
# Change of username?
|
||||
if "username" in data and len(data["username"]) and data["username"] != db["username"]:
|
||||
JsonDB.delete("users/by-name/{}".format(db["username"]))
|
||||
JsonDB.commit("users/by-name/{}".format(data["username"]), dict(
|
||||
uid=int(uid),
|
||||
))
|
||||
|
||||
db.update(data)
|
||||
JsonDB.commit("users/by-id/{}".format(uid), db)
|
||||
|
||||
|
||||
def delete_user(uid):
|
||||
"""Delete a user account."""
|
||||
if not exists(uid=uid):
|
||||
return
|
||||
|
||||
db = get_user(uid=uid)
|
||||
username = db["username"]
|
||||
|
||||
# Mark the account deleted.
|
||||
update_user(uid, dict(
|
||||
username="",
|
||||
name="",
|
||||
role="deleted",
|
||||
password="!",
|
||||
))
|
||||
|
||||
# Delete their username.
|
||||
JsonDB.delete("users/by-name/{}".format(username))
|
||||
|
||||
|
||||
def list_users():
|
||||
"""Get a sorted list of all users."""
|
||||
uids = JsonDB.list_docs("users/by-id")
|
||||
users = list()
|
||||
for uid in sorted(map(lambda x: int(x), uids)):
|
||||
db = get_user(uid=uid)
|
||||
if db["role"] == "deleted": continue
|
||||
users.append(db)
|
||||
return users
|
||||
|
||||
|
||||
def get_uid(username):
|
||||
"""Turn a username into a user ID."""
|
||||
db = JsonDB.get("users/by-name/{}".format(username))
|
||||
if db:
|
||||
return int(db["uid"])
|
||||
return None
|
||||
|
||||
|
||||
def get_user(uid=None, username=None):
|
||||
"""Get a user's DB file, or None if not found."""
|
||||
if username:
|
||||
uid = get_uid(username)
|
||||
logger.debug("get_user: resolved username {} to UID {}".format(username, uid))
|
||||
return JsonDB.get("users/by-id/{}".format(uid))
|
||||
|
||||
|
||||
def exists(uid=None, username=None):
|
||||
"""Query whether a user ID or name exists."""
|
||||
if uid:
|
||||
return JsonDB.exists("users/by-id/{}".format(uid))
|
||||
elif username:
|
||||
return JsonDB.exists("users/by-name/{}".format(username.lower()))
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
return bcrypt.hashpw(str(password), bcrypt.gensalt(config.BCRYPT_ITERATIONS))
|
||||
|
||||
|
||||
def check_auth(username, password):
|
||||
"""Check the authentication credentials for the username and password.
|
||||
|
||||
Returns a boolean true or false. On error, an error is logged."""
|
||||
# Check if the username exists.
|
||||
if not exists(username=username):
|
||||
logger.error("User authentication failed: username {} not found!".format(username))
|
||||
return False
|
||||
|
||||
# Get the user's file.
|
||||
db = get_user(username=username)
|
||||
print db
|
||||
|
||||
# Check the password.
|
||||
return bcrypt.hashpw(str(password), str(db["password"])) == db["password"]
|
||||
|
||||
|
||||
def get_next_uid():
|
||||
"""Get the next available user ID."""
|
||||
uid = 1
|
||||
while exists(uid=uid):
|
||||
uid += 1
|
||||
return uid
|
|
@ -1,9 +1,129 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from flask import Blueprint
|
||||
"""Endpoints for user login and out."""
|
||||
|
||||
from flask import Blueprint, request, redirect, url_for, session, flash
|
||||
import re
|
||||
|
||||
import rophako.model.user as User
|
||||
from rophako.utils import template
|
||||
|
||||
mod = Blueprint("account", __name__, url_prefix="/account")
|
||||
|
||||
@mod.route("/")
|
||||
def index():
|
||||
return "Test"
|
||||
return redirect(url_for(".login"))
|
||||
|
||||
|
||||
@mod.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Log into an account."""
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "")
|
||||
password = request.form.get("password", "")
|
||||
|
||||
# Lowercase the username.
|
||||
username = username.lower()
|
||||
|
||||
if User.check_auth(username, password):
|
||||
# OK!
|
||||
db = User.get_user(username=username)
|
||||
session["login"] = True
|
||||
session["username"] = username
|
||||
session["uid"] = db["uid"]
|
||||
session["name"] = db["name"]
|
||||
session["role"] = db["role"]
|
||||
return redirect(url_for("index"))
|
||||
else:
|
||||
flash("Authentication failed.")
|
||||
return redirect(url_for(".login"))
|
||||
|
||||
return template("account/login.html")
|
||||
|
||||
|
||||
@mod.route("/logout")
|
||||
def logout():
|
||||
"""Log out the user."""
|
||||
session["login"] = False
|
||||
session["username"] = "guest"
|
||||
session["uid"] = 0
|
||||
session["name"] = "Guest"
|
||||
session["role"] = "user"
|
||||
|
||||
flash("You have been signed out.")
|
||||
return redirect(url_for(".login"))
|
||||
|
||||
|
||||
@mod.route("/setup", methods=["GET", "POST"])
|
||||
def setup():
|
||||
"""Initial setup to create the Admin user account."""
|
||||
|
||||
# This can't be done if users already exist on the CMS!
|
||||
if User.exists(uid=1):
|
||||
flash("This website has already been configured (users already created).")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if request.method == "POST":
|
||||
# Submitting the form.
|
||||
username = request.form.get("username", "")
|
||||
name = request.form.get("name", "")
|
||||
pw1 = request.form.get("password1", "")
|
||||
pw2 = request.form.get("password2", "")
|
||||
|
||||
# Default name = username.
|
||||
if name == "":
|
||||
name = username
|
||||
|
||||
# Lowercase the user.
|
||||
username = username.lower()
|
||||
if User.exists(username=username):
|
||||
flash("That username already exists.")
|
||||
return redirect(url_for(".setup"))
|
||||
|
||||
# Validate the form.
|
||||
errors = validate_create_form(username, pw1, pw2)
|
||||
if errors:
|
||||
for error in errors:
|
||||
flash(error)
|
||||
return redirect(url_for(".setup"))
|
||||
|
||||
# Create the account.
|
||||
uid = User.create(
|
||||
username=username,
|
||||
password=pw1,
|
||||
name=name,
|
||||
role="admin",
|
||||
)
|
||||
|
||||
flash("Admin user created! Please log in now.".format(uid))
|
||||
return redirect(url_for(".login"))
|
||||
|
||||
|
||||
return template("account/setup.html")
|
||||
|
||||
|
||||
def validate_create_form(username, pw1=None, pw2=None, skip_passwd=False):
|
||||
"""Validate the submission of a create-user form.
|
||||
|
||||
Returns a list of error messages if there were errors, otherwise
|
||||
it returns None."""
|
||||
errors = list()
|
||||
|
||||
if len(username) == 0:
|
||||
errors.append("You must provide a username.")
|
||||
|
||||
if re.search(r'[^A-Za-z0-9-_]', username):
|
||||
errors.append("Usernames can only contain letters, numbers, dashes or underscores.")
|
||||
|
||||
if not skip_passwd:
|
||||
if len(pw1) < 3:
|
||||
errors.append("You should use at least 3 characters in your password.")
|
||||
|
||||
if pw1 != pw2:
|
||||
errors.append("Your passwords don't match.")
|
||||
|
||||
if len(errors):
|
||||
return errors
|
||||
else:
|
||||
return None
|
183
rophako/modules/admin.py
Normal file
183
rophako/modules/admin.py
Normal file
|
@ -0,0 +1,183 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Endpoints for admin functions."""
|
||||
|
||||
from flask import g, Blueprint, request, redirect, url_for, session, flash
|
||||
import re
|
||||
|
||||
import rophako.model.user as User
|
||||
from rophako.modules.account import validate_create_form
|
||||
from rophako.utils import template, admin_required
|
||||
|
||||
mod = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
@mod.route("/")
|
||||
@admin_required
|
||||
def index():
|
||||
return template("admin/index.html")
|
||||
|
||||
|
||||
@mod.route("/users")
|
||||
@admin_required
|
||||
def users():
|
||||
# Get the list of existing users.
|
||||
users = User.list_users()
|
||||
|
||||
return template("admin/users.html",
|
||||
users=users,
|
||||
)
|
||||
|
||||
|
||||
@mod.route("/users/create", methods=["POST"])
|
||||
@admin_required
|
||||
def create_user():
|
||||
# Submitting the form.
|
||||
username = request.form.get("username", "")
|
||||
name = request.form.get("name", "")
|
||||
pw1 = request.form.get("password1", "")
|
||||
pw2 = request.form.get("password2", "")
|
||||
role = request.form.get("role", "")
|
||||
|
||||
# Default name = username.
|
||||
if name == "":
|
||||
name = username
|
||||
|
||||
# Lowercase the user.
|
||||
username = username.lower()
|
||||
if User.exists(username=username):
|
||||
flash("That username already exists.")
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
# Validate the form.
|
||||
errors = validate_create_form(username, pw1, pw2)
|
||||
if errors:
|
||||
for error in errors:
|
||||
flash(error)
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
# Create the account.
|
||||
uid = User.create(
|
||||
username=username,
|
||||
password=pw1,
|
||||
name=name,
|
||||
role=role,
|
||||
)
|
||||
|
||||
flash("User created!")
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
|
||||
@mod.route("/users/edit/<uid>", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def edit_user(uid):
|
||||
uid = int(uid)
|
||||
user = User.get_user(uid=uid)
|
||||
|
||||
# Submitting?
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action", "")
|
||||
username = request.form.get("username", "")
|
||||
name = request.form.get("name", "")
|
||||
pw1 = request.form.get("password1", "")
|
||||
pw2 = request.form.get("password2", "")
|
||||
role = request.form.get("role", "")
|
||||
|
||||
username = username.lower()
|
||||
|
||||
if action == "save":
|
||||
# Validate...
|
||||
errors = None
|
||||
|
||||
# Don't allow them to change the username to one that exists.
|
||||
if username != user["username"]:
|
||||
if User.exists(username=username):
|
||||
flash("That username already exists.")
|
||||
return redirect(url_for(".edit_user", uid=uid))
|
||||
|
||||
# Password provided?
|
||||
if len(pw1) > 0:
|
||||
errors = validate_create_form(username, pw1, pw2)
|
||||
elif username != user["username"]:
|
||||
# Just validate the username, then.
|
||||
errors = validate_create_form(username, skip_passwd=True)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
flash(error)
|
||||
return redirect(url_for(".edit_user", uid=uid))
|
||||
|
||||
# Update the user.
|
||||
user["username"] = username
|
||||
user["name"] = name or username
|
||||
user["role"] = role
|
||||
if len(pw1) > 0:
|
||||
user["password"] = User.hash_password(pw1)
|
||||
User.update_user(uid, user)
|
||||
|
||||
flash("User account updated!")
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
elif action == "delete":
|
||||
# Don't let them delete themself!
|
||||
if uid == g.info["session"]["uid"]:
|
||||
flash("You shouldn't delete yourself!")
|
||||
return redirect(url_for(".edit_user", uid=uid))
|
||||
|
||||
User.delete_user(uid)
|
||||
flash("User deleted!")
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
return template("admin/edit_user.html",
|
||||
info=user,
|
||||
)
|
||||
|
||||
|
||||
@mod.route("/impersonate/<int:uid>")
|
||||
@admin_required
|
||||
def impersonate(uid):
|
||||
"""Impersonate a user."""
|
||||
# Check that they exist.
|
||||
if not User.exists(uid=uid):
|
||||
flash("That user ID wasn't found.")
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
db = User.get_user(uid=uid)
|
||||
if db["role"] == "deleted":
|
||||
flash("That user was deleted!")
|
||||
return redirect(url_for(".users"))
|
||||
|
||||
# Log them in!
|
||||
orig_uid = session["uid"]
|
||||
session.update(
|
||||
login=True,
|
||||
uid=uid,
|
||||
username=db["username"],
|
||||
name=db["name"],
|
||||
role=db["role"],
|
||||
impersonator=orig_uid,
|
||||
)
|
||||
|
||||
flash("Now logged in as {}".format(db["name"]))
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@mod.route("/unimpersonate")
|
||||
def unimpersonate():
|
||||
"""Unimpersonate a user."""
|
||||
|
||||
# Must be impersonating, first!
|
||||
if not "impersonator" in session:
|
||||
flash("Stop messing around.")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
uid = session.pop("impersonator")
|
||||
db = User.get_user(uid=uid)
|
||||
session.update(
|
||||
login=True,
|
||||
uid=uid,
|
||||
username=db["username"],
|
||||
name=db["name"],
|
||||
role=db["role"],
|
||||
)
|
||||
|
||||
flash("No longer impersonating.")
|
||||
return redirect(url_for("index"))
|
51
rophako/utils.py
Normal file
51
rophako/utils.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from flask import g, session, request, render_template
|
||||
from functools import wraps
|
||||
import uuid
|
||||
|
||||
from rophako.log import logger
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Wrapper for pages that require a logged-in user."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not g.info["session"]["login"]:
|
||||
session["redirect_url"] = request.url
|
||||
flash("You must be logged in to do that!")
|
||||
return redirect(url_for("account.login"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Wrapper for admin-only pages. Implies login_required."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not g.info["session"]["login"]:
|
||||
# Not even logged in?
|
||||
session["redirect_url"] = request.url
|
||||
flash("You must be logged in to do that!")
|
||||
return redirect(url_for("account.login"))
|
||||
|
||||
if g.info["session"]["role"] != "admin":
|
||||
logger.warning("User tried to access an Admin page, but wasn't allowed!")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def template(name, **kwargs):
|
||||
"""Render a template to the browser."""
|
||||
|
||||
html = render_template(name, **kwargs)
|
||||
return html
|
||||
|
||||
|
||||
def generate_csrf_token():
|
||||
"""Generator for CSRF tokens."""
|
||||
if "_csrf" not in session:
|
||||
session["_csrf"] = str(uuid.uuid4())
|
||||
return session["_csrf"]
|
23
rophako/www/account/login.html
Normal file
23
rophako/www/account/login.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% extends "layout.html" %}
|
||||
{% block title %}Log In{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Log In</h1>
|
||||
|
||||
<form action="{{ url_for('account.login') }}" method="POST">
|
||||
<input type="hidden" name="token" value="{{ csrf_token() }}">
|
||||
<fieldset>
|
||||
<legend>Log In</legend>
|
||||
|
||||
<strong>Username:</strong><br>
|
||||
<input type="text" size="20" name="username" id="username"><p>
|
||||
|
||||
<strong>Passphrase:</strong><br>
|
||||
<input type="password" size="20" name="password"><p>
|
||||
|
||||
<button type="submit">Log In</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
42
rophako/www/account/setup.html
Normal file
42
rophako/www/account/setup.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
{% extends "layout.html" %}
|
||||
{% block title %}Initial Setup{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="text/javascript" src="/rophako/setup.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Welcome to Rophako!</h1>
|
||||
|
||||
This is the initial setup for the Rophako CMS. The main purpose of this is
|
||||
to create the initial Administrator user account.<p>
|
||||
|
||||
<form id="setup_form" action="{{ url_for('account.setup') }}" method="POST">
|
||||
<input type="hidden" name="token" value="{{ csrf_token() }}">
|
||||
<fieldset>
|
||||
<legend>Admin User</legend>
|
||||
|
||||
Your site needs at least one admin user to log in and manage the site.
|
||||
You can use any username/password combination you want, but "admin" is
|
||||
a typical username.<p>
|
||||
|
||||
<strong>Username:</strong><br>
|
||||
<input type="text" size="40" name="username" id="username" placeholder="admin"><p>
|
||||
|
||||
<strong>Real name:</strong><br>
|
||||
<input type="text" size="40" name="name" placeholder="John Doe"><p>
|
||||
|
||||
<strong>Passphrase:</strong><br>
|
||||
This can be as long as you want. Pick something
|
||||
<a href="http://xkcd.com/936/" target="_blank">secure!</a><br>
|
||||
<input type="password" size="40" id="pw1" name="password1" placeholder="correct horse battery staple"><p>
|
||||
|
||||
<strong>Confirm Passphrase:</strong><br>
|
||||
<input type="password" size="40" id="pw2" name="password2" placeholder="correct horse battery staple"><p>
|
||||
|
||||
<button type="submit">Next</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
44
rophako/www/admin/edit_user.html
Normal file
44
rophako/www/admin/edit_user.html
Normal file
|
@ -0,0 +1,44 @@
|
|||
{% extends "layout.html" %}
|
||||
{% block title %}Admin Center{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$("#delete_button").click(function() {
|
||||
return window.confirm("Are you sure?");
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Edit User #{{ info["uid"] }}</h1>
|
||||
|
||||
<form action="{{ url_for('admin.edit_user', uid=info['uid']) }}" method="POST">
|
||||
<input type="hidden" name="token" value="{{ csrf_token() }}">
|
||||
<fieldset>
|
||||
<legend>User Details</legend>
|
||||
|
||||
<strong>Username:</strong><br>
|
||||
<input type="text" size="20" name="username" value="{{ info['username'] }}"><p>
|
||||
|
||||
<strong>Real name:</strong><br>
|
||||
<input type="text" size="20" name="name" value="{{ info['name'] }}"><p>
|
||||
|
||||
<strong>Reset Password:</strong><br>
|
||||
<input type="password" size="20" name="password1"><br>
|
||||
<input type="password" size="20" name="password2"><p>
|
||||
|
||||
<strong>Role:</strong><br>
|
||||
<select name="role">
|
||||
<option value="user"{% if info["role"] == "user" %} selected{% endif %}>User</option>
|
||||
<option value="admin"{% if info["role"] == "admin" %} selected{% endif %}>Admin</option>
|
||||
</select><p>
|
||||
|
||||
<button type="submit" name="action" value="save">Save Changes</button>
|
||||
<button type="submit" name="action" id="delete_button" value="delete">Delete User</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
12
rophako/www/admin/index.html
Normal file
12
rophako/www/admin/index.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% extends "layout.html" %}
|
||||
{% block title %}Admin Center{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Admin Center</h1>
|
||||
|
||||
<ul>
|
||||
<li><a href="{{ url_for('admin.users') }}">View and Manage Users</a></li>
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
68
rophako/www/admin/users.html
Normal file
68
rophako/www/admin/users.html
Normal file
|
@ -0,0 +1,68 @@
|
|||
{% extends "layout.html" %}
|
||||
{% block title %}Admin Center{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>User Management</h1>
|
||||
|
||||
<h2>Create New User</h2>
|
||||
|
||||
<form action="{{ url_for('admin.create_user') }}" method="POST">
|
||||
<input type="hidden" name="token" value="{{ csrf_token() }}">
|
||||
<fieldset>
|
||||
<legend>Create New User</legend>
|
||||
|
||||
<strong>Username:</strong><br>
|
||||
<input type="text" size="40" name="username" placeholder="soandso"><p>
|
||||
|
||||
<strong>Real name:</strong><br>
|
||||
<input type="text" size="40" name="name" placeholder="John Smith"><p>
|
||||
|
||||
<strong>Passphrase:</strong><br>
|
||||
<input type="password" size="40" name="password1" placeholder="correct horse battery staple"><p>
|
||||
|
||||
<strong>Confirm:</strong><br>
|
||||
<input type="password" size="40" name="password2" placeholder="correct horse battery staple"><p>
|
||||
|
||||
<strong>Role:</strong><br>
|
||||
<select name="role">
|
||||
<option value="user" selected>User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
|
||||
<button type="submit">Create</button>
|
||||
</fieldset>
|
||||
|
||||
<h2>User List</h2>
|
||||
|
||||
<table class="table table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="100">User ID</th>
|
||||
<th width="300">Username</th>
|
||||
<th>Real name</th>
|
||||
<th width="100">Role</th>
|
||||
<th width="100">Log in</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user["uid"] }}</td>
|
||||
<td><a href="{{ url_for('admin.edit_user', uid=user['uid']) }}">{{ user["username"] }}</a></td>
|
||||
<td>{{ user["name"] }}</td>
|
||||
<td>{{ user["role"] }}</td>
|
||||
<td>
|
||||
{% if user["role"] != "admin" %}
|
||||
<a href="{{ url_for('admin.impersonate', uid=user['uid']) }}">Log in as</a>
|
||||
{% else %}
|
||||
n/a
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
4
rophako/www/js/jquery-2.1.0.min.js
vendored
Normal file
4
rophako/www/js/jquery-2.1.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -17,6 +17,13 @@
|
|||
<li><a href="/">Home</a></li>
|
||||
<li><a href="#">About Rophako</a></li>
|
||||
<li><a href="#">Download</a></li>
|
||||
|
||||
<li class="header">:: Site Admin</li>
|
||||
{% if session["login"] %}
|
||||
<li><a href="{{ url_for('account.logout') }}">Log out</a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('account.login') }}">Log in</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
@ -35,5 +42,8 @@
|
|||
</a>
|
||||
</footer>
|
||||
|
||||
<script type="text/javascript" src="/js/jquery-2.1.0.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
31
rophako/www/rophako/setup.js
Normal file
31
rophako/www/rophako/setup.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
/* rophako cms
|
||||
-----------
|
||||
Script for /account/setup
|
||||
*/
|
||||
|
||||
$(document).ready(function() {
|
||||
$("#setup_form").submit(function() {
|
||||
var username = $("#username").val(),
|
||||
pw1 = $("#pw1").val(),
|
||||
pw2 = $("#pw2").val();
|
||||
|
||||
if (username.length === 0) {
|
||||
window.alert("The username is required.");
|
||||
}
|
||||
else if (username.match(/[^A-Za-z0-9_-]+/)) {
|
||||
window.alert("The username should only contain numbers, letters, underscores or dashes.");
|
||||
}
|
||||
else if (pw1.length < 3) {
|
||||
window.alert("Your password should have at least three characters.");
|
||||
}
|
||||
else if (pw1 !== pw2) {
|
||||
window.alert("Your passwords don't match.");
|
||||
}
|
||||
else {
|
||||
// All good!
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
})
|
Loading…
Reference in New Issue
Block a user