Browse Source

Projects Update

* Conversion script to new configuration layout. This is to prevent from secrets being loaded into memory aside from when they are needed.
* Updated code throughout the project to account for the new methods.
* New classes and code for new configuration layout
* New Javascript for backend to control all the dialogues
* Moved modals to separate files
* Smaller database model update
* Removed redundant code
* Added database upgrade path via the dashboard
* Admin password is now properly hashed
* You can now export all locations manually in event of an error
projects
Domeniko Gentner 10 months ago
parent
commit
ad8f68cdd8
  1. 7
      .gitignore
  2. 1
      js/labertasche.js
  3. 72
      labertasche/blueprints/bp_comments/__init__.py
  4. 22
      labertasche/blueprints/bp_jsconnector/comments.py
  5. 141
      labertasche/blueprints/bp_jsconnector/projects.py
  6. 8
      labertasche/blueprints/bp_login/__init__.py
  7. 6
      labertasche/blueprints/bp_upgrades/db_v2.py
  8. 94
      labertasche/helper/__init__.py
  9. 21
      labertasche/mail/__init__.py
  10. 4
      labertasche/models/t_projects.py
  11. 145
      labertasche/settings/__init__.py
  12. 37
      server.py
  13. 368
      static/js/dashboard.js
  14. 8
      templates/base.html
  15. 10
      templates/login.html
  16. 22
      templates/modals/comments-export-all.html
  17. 21
      templates/modals/project-delete.html
  18. 109
      templates/modals/project_edit.html
  19. 24
      templates/modals/project_not_found.html
  20. 149
      templates/project-list.html

7
.gitignore

@ -4,8 +4,11 @@ venv
db/*.db
db/*.db-shm
db/*.db-wal
output
/output/
*.old
*.server
/backup/
labertasche.yaml
/.secret
/credentials.yaml
*.bak
smileys.yaml

1
js/labertasche.js

@ -15,6 +15,7 @@
// post-internal-server-error
// post-success
// post-before-fetch
// post-project-not-found
function labertasche_callback(state)
{
if (state === "post-before-fetch"){

72
labertasche/blueprints/bp_comments/__init__.py

@ -15,8 +15,8 @@ from sqlalchemy import exc
from labertasche.database import labertasche_db as db
from labertasche.helper import is_valid_json, default_timestamp, check_gravatar, export_location
from labertasche.mail import mail
from labertasche.models import TComments, TLocation, TEmail
from labertasche.settings import Settings
from labertasche.models import TComments, TLocation, TEmail, TProjects
from labertasche.settings import Smileys
from secrets import compare_digest
@ -25,19 +25,17 @@ bp_comments = Blueprint("bp_comments", __name__, url_prefix='/comments')
# Route for adding new comments
@bp_comments.route("/new", methods=['POST'])
@bp_comments.route("/<name>/new", methods=['POST'])
@cross_origin()
def check_and_insert_new_comment():
def check_and_insert_new_comment(name):
if request.method == 'POST':
settings = Settings()
smileys = settings.smileys
addons = settings.addons
smileys = Smileys()
sender = mail()
# Check length of content and abort if too long or too short
if request.content_length > 2048:
return make_response(jsonify(status="post-max-length"), 400)
if request.content_length <= 0:
if request.content_length == 0:
return make_response(jsonify(status="post-min-length"), 400)
# get json from request
@ -59,9 +57,14 @@ def check_and_insert_new_comment():
content = re.sub(tags, '', new_comment['content']).strip()
content = re.sub(special, '', content).strip()
# Convert smileys
if addons['smileys']:
for key, value in smileys.items():
# Get project
project = db.session.query(TProjects).filter(TProjects.name == name).first()
if not project:
return make_response(jsonify(status="post-project-not-found"), 400)
# Convert smileys if enabled
if project.addon_smileys:
for key, value in smileys.smileys.items():
content = content.replace(key, value)
# Validate replied_to field is integer
@ -119,7 +122,8 @@ def check_and_insert_new_comment():
else:
# Insert new location
loc_table = {
'location': new_comment['location']
'location': new_comment['location'],
'project_id': project.id_project
}
new_loc = TLocation(**loc_table)
db.session.add(new_loc)
@ -133,11 +137,15 @@ def check_and_insert_new_comment():
# insert comment
# noinspection PyBroadException
try:
new_comment.update({"is_published": False})
if project.sendotp:
new_comment.update({"is_published": False})
else:
new_comment.update({"is_published": True})
new_comment.update({"created_on": default_timestamp()})
new_comment.update({"is_spam": is_spam})
new_comment.update({"spam_score": has_score})
new_comment.update({"gravatar": check_gravatar(new_comment['email'])})
new_comment.update({"gravatar": check_gravatar(new_comment['email'], project.name)})
new_comment.update({"project_id": project.id_project})
t_comment = TComments(**new_comment)
db.session.add(t_comment)
db.session.commit()
@ -145,10 +153,11 @@ def check_and_insert_new_comment():
db.session.refresh(t_comment)
# Send confirmation link and store returned value
hashes = sender.send_confirmation_link(new_comment['email'])
setattr(t_comment, "confirmation", hashes[0])
setattr(t_comment, "deletion", hashes[1])
db.session.commit()
if project.sendotp:
hashes = sender.send_confirmation_link(new_comment['email'], project.name)
setattr(t_comment, "confirmation", hashes[0])
setattr(t_comment, "deletion", hashes[1])
db.session.commit()
except exc.IntegrityError as e:
# Comment body exists, because content is unique
@ -163,11 +172,12 @@ def check_and_insert_new_comment():
# Route for confirming comments
@bp_comments.route("/confirm/<email_hash>", methods=['GET'])
@bp_comments.route("/confirm/<name>/<email_hash>", methods=['GET'])
@cross_origin()
def check_confirmation_link(email_hash):
settings = Settings()
def check_confirmation_link(name, email_hash):
comment = db.session.query(TComments).filter(TComments.confirmation == email_hash).first()
project = db.session.query(TProjects).filter(TProjects.name == name).first()
if comment:
location = db.session.query(TLocation).filter(TLocation.id_location == comment.location_id).first()
if compare_digest(comment.confirmation, email_hash):
@ -175,27 +185,27 @@ def check_confirmation_link(email_hash):
if not comment.is_spam:
setattr(comment, "is_published", True)
db.session.commit()
url = f"{settings.system['blog_url']}{location.location}#comment_{comment.comments_id}"
url = f"{project.blogurl}{location.location}#comment_{comment.comments_id}"
export_location(location.id_location)
return redirect(url)
return redirect(f"{settings.system['blog_url']}?cnf=true")
return redirect(f"{project.blogurl}?cnf=true")
# Route for deleting comments
@bp_comments.route("/delete/<email_hash>", methods=['GET'])
@bp_comments.route("/delete/<name>/<email_hash>", methods=['GET'])
@cross_origin()
def check_deletion_link(email_hash):
settings = Settings()
query = db.session.query(TComments).filter(TComments.deletion == email_hash)
comment = query.first()
def check_deletion_link(name, email_hash):
project = db.session.query(TProjects).filter(TProjects.name == name).first()
comment = db.session.query(TComments).filter(TComments.deletion == email_hash).first()
if comment:
location = db.session.query(TLocation).filter(TLocation.id_location == comment.location_id).first()
if compare_digest(comment.deletion, email_hash):
query.delete()
comment.delete()
db.session.commit()
url = f"{settings.system['blog_url']}?deleted=true"
url = f"{project.blogurl}?deleted=true"
export_location(location.id_location)
return redirect(url)
return redirect(f"{settings.system['blog_url']}?cnf=true")
return redirect(f"{project.blogurl}?cnf=true")

22
labertasche/blueprints/bp_jsconnector/comments.py

@ -7,12 +7,12 @@
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_jsconnector
from flask import request, redirect
from flask import request, redirect, make_response, jsonify
from flask_login import login_required
from flask_cors import cross_origin
from labertasche.database import labertasche_db as db
from labertasche.helper import export_location
from labertasche.models import TComments, TEmail
from labertasche.helper import export_location, get_id_from_project_name
from labertasche.models import TComments, TEmail, TLocation
# This file contains the routes for the manage comments menu point.
# They are called via GET
@ -112,3 +112,19 @@ def api_comment_block_mail(comment_id):
return redirect(request.referrer)
@cross_origin()
@bp_jsconnector.route('/comment-export-all/<name>', methods=["GET"])
@login_required
def api_export_all_by_project(name):
proj_id = get_id_from_project_name(name)
if proj_id == -1:
return make_response(jsonify(status='not-found'), 400)
try:
locations = db.session.query(TLocation).all()
for each in locations:
export_location(each.id_location)
except Exception as e:
return make_response(jsonify(status='sql-error', msg=str(e)), 400)
return make_response(jsonify(status='ok'), 200)

141
labertasche/blueprints/bp_jsconnector/projects.py

@ -13,9 +13,57 @@ from flask_cors import cross_origin
from labertasche.database import labertasche_db as db
from labertasche.helper import get_id_from_project_name
from labertasche.models import TProjects, TComments, TEmail, TLocation
from validators import url as validate_url
from pathlib import Path
import re
def validate_project(project):
"""
Validates important bits of a project database entry
:param project: The json from the request, containing the data for a project.
:return: A response with the error or None if the project is valid.
"""
# Validate length
if not len(project['name']) and \
not len(project['blogurl']) and \
not len(project['output']):
return make_response(jsonify(status='too-short'), 400)
# Validate project name
if not re.match('^\\w+$', project['name']):
print(project['name'])
return make_response(jsonify(status='invalid-project-name'), 400)
# Check if project name already exists
name_check = db.session.query(TProjects.name).filter(TProjects.name == project['name']).first()
if name_check:
return make_response(jsonify(status='project-exists'), 400)
# Validate url
url_exists = db.session.query(TProjects.blogurl).filter(TProjects.blogurl == project['blogurl']).first()
if not validate_url(project['blogurl']) or url_exists:
return make_response(jsonify(status='invalid-blog-url'), 400)
# Validate output path
output = Path(project['output']).absolute()
# The second check is needed, since javascript is passing an empty string instead of
# null. For some reason, this makes SQLAlchemy accept the data and commit it to the db
# without exception. This check prevents this issue from happening.
if not output.exists() or len(project['output'].strip()) == 0:
return make_response(jsonify(status='invalid-path-output'), 400)
# Validate cache path, if caching is enabled
if project['gravatar_cache']:
cache = Path(project['gravatar_cache_dir']).absolute()
if not cache.exists() or len(project['gravatar_cache_dir'].strip()) == 0:
return make_response(jsonify(status='invalid-path-cache'), 400)
return None
@cross_origin()
@bp_jsconnector.route("/project/new", methods=['POST'])
@login_required
@ -25,58 +73,64 @@ def api_create_project():
:return: A string with an error code and 'ok' as string on success.
"""
# TODO: Project name exists?
name = request.json['name']
response = validate_project(request.json)
if response is not None:
return response
if not len(name):
return make_response(jsonify(status='too-short'), 400)
if not re.match('^\\w+$', name):
return make_response(jsonify(status='invalid-name'), 400)
proj = TProjects(name=name)
db.session.add(proj)
db.session.commit()
try:
db.session.add(TProjects(**request.json))
db.session.commit()
except Exception as e:
print(str(e))
db.session.rollback()
return make_response(jsonify(status='exception', msg=str(e)), 500)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_jsconnector.route('project/edit/<name>', methods=['POST'])
@bp_jsconnector.route('/project/edit/<name>', methods=['POST'])
@login_required
def api_edit_project_name(name: str):
"""
Renames the project.
:param name:
:param name: The previous name of the project to edit, must exist
:return: A string with an error code and 'ok' as string on success.
"""
# TODO: Project name exists?
new_name = request.json['name']
response = validate_project(request.json)
if response is not None:
return response
if not len(new_name):
return make_response(jsonify(status='too-short'), 400)
if not re.match('^\\w+$', new_name):
return make_response(jsonify(status='invalid-name'), 400)
proj_id = get_id_from_project_name(name)
project = db.session.query(TProjects).filter(TProjects.id_project == proj_id)
setattr(project, 'name', new_name)
db.session.upate(project)
db.session.commit()
try:
project = db.session.query(TProjects).filter(TProjects.name == name).first()
setattr(project, "id_project", project.id_project)
setattr(project, "name", request.json['name'])
setattr(project, "blogurl", request.json['blogurl'].strip())
setattr(project, "output", request.json['output'].strip())
setattr(project, "sendotp", request.json['sendotp'])
setattr(project, "gravatar_cache", request.json['gravatar_cache'])
setattr(project, "gravatar_cache_dir", request.json['gravatar_cache_dir'])
setattr(project, "gravatar_size", request.json['gravatar_size'])
setattr(project, "addon_smileys", request.json['addon_smileys'])
db.session.commit()
except Exception as e:
print(str(e))
return make_response(jsonify(status='exception', msg=str(e)), 500)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_jsconnector.route('project/delete/<project>', methods=['GET'])
@bp_jsconnector.route('/project/delete/<name>', methods=['GET'])
@login_required
def api_delete_project(project: str):
def api_delete_project(name: str):
"""
Deletes a project from the database and all associated data
:param project: The name of the project
:param name: The name of the project
:return: A string with an error code and 'ok' as string on success.
"""
proj_id = get_id_from_project_name(project)
proj_id = get_id_from_project_name(name)
if proj_id == -1:
return make_response(jsonify(status='not-found'), 400)
@ -92,3 +146,34 @@ def api_delete_project(project: str):
return make_response(jsonify(status='exception'), 400)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_jsconnector.route('/project/exists/<name>', methods=['GET'])
@login_required
def api_project_exists(name: str):
proj_id = get_id_from_project_name(name)
if proj_id == -1:
return make_response(jsonify(status='not-found'), 200)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_jsconnector.route('/project/get/<name>', methods=['GET'])
@login_required
def api_project_get_data(name: str):
project = db.session.query(TProjects).filter(TProjects.name == name).first()
if project:
return make_response(jsonify(status='ok',
id_project=project.id_project,
name=project.name,
blogurl=project.blogurl,
output=project.output,
sendotp=project.sendotp,
gravatar_cache=project.gravatar_cache,
gravatar_cache_dir=project.gravatar_cache_dir,
gravatar_size=project.gravatar_size,
addon_smileys=project.addon_smileys), 200)
else:
print('400')
return make_response(jsonify(status='not-found'), 400)

8
labertasche/blueprints/bp_login/__init__.py

@ -8,7 +8,9 @@
# *********************************************************************************/
from flask import Blueprint, render_template, request, redirect, url_for
from flask_cors import cross_origin
from labertasche.helper import check_auth, User
from labertasche.helper import User
from labertasche.settings import Credentials
from secrets import compare_digest
from flask_login import login_user, current_user, logout_user
# Blueprint
@ -30,7 +32,9 @@ def login():
username = request.form['username']
password = request.form['password']
if check_auth(username, password):
credentials = Credentials()
if compare_digest(username.encode('utf8'), credentials.username.encode('utf8')) and \
credentials.compare_password(password):
login_user(User(0), remember=True)
return redirect(url_for('bp_dashboard.dashboard_project_list'))

6
labertasche/blueprints/bp_upgrades/db_v2.py

@ -13,7 +13,7 @@ from flask import render_template, jsonify, make_response
from pathlib import Path
from labertasche.database import labertasche_db as db
from labertasche.models import TProjects, TComments, TLocation, TEmail, TVersion
from labertasche.helper import Settings
from labertasche.settings import LegacySettings
from json import dump, load
from shutil import copy, make_archive
from re import search
@ -128,7 +128,7 @@ def upgrade_db_to_v2_export():
# Copy database
try:
settings = Settings()
settings = LegacySettings(True)
db_uri = settings.system['database_uri']
if compare_digest(db_uri[0:6], "sqlite"):
m = search("([/]{3})(.*)", db_uri)
@ -163,7 +163,7 @@ def upgrade_db_to_v2_recreate():
@login_required
def upgrade_db_to_v2_import():
path = get_backup_folder()
settings = Settings()
settings = LegacySettings(True)
try:
# load location

94
labertasche/helper/__init__.py

@ -8,21 +8,19 @@
# *********************************************************************************/
import datetime
import json
from labertasche.models import TLocation, TComments, TProjects
from labertasche.settings import Settings
from labertasche.database import labertasche_db as db
from functools import wraps
from hashlib import md5
from flask import request
from flask_login import UserMixin
from secrets import compare_digest
from pathlib import Path
from sys import stderr
from re import match as re_match
import requests
from labertasche.models import TLocation, TComments, TProjects
from labertasche.database import labertasche_db as db
class User(UserMixin):
"""
Class for flask-login, which represents a user
"""
def __init__(self, user_id):
self.id = user_id
@ -37,7 +35,7 @@ def is_valid_json(j):
try:
json.dumps(j)
return True
except json.JSONDecodeError as e:
except json.JSONDecodeError:
print("not valid json")
return False
@ -77,60 +75,30 @@ def alchemy_query_to_dict(obj):
# Come on, it's a mail hash, don't complain
# noinspection InsecureHash
def check_gravatar(email: str):
def check_gravatar(email: str, name: str):
"""
Builds the gravatar email hash, which uses md5.
You may use ?size=128 for example to dictate size in the final template.
:param email: the email to use for the hash
:param name: The project name
:return: the gravatar url of the image
"""
settings = Settings()
options = settings.gravatar
from requests import get
project = db.session.query(TProjects).filter(TProjects.name == name).first()
gravatar_hash = md5(email.strip().lower().encode("utf8")).hexdigest()
if options['cache']:
url = f"https://www.gravatar.com/avatar/{gravatar_hash}?s={options['size']}"
response = requests.get(url)
if project.gravatar_cache:
url = f"https://www.gravatar.com/avatar/{gravatar_hash}?s={project.gravatar_size}"
response = get(url)
if response.ok:
outfile = Path(f"{options['static_dir']}/{gravatar_hash}.jpg")
outfile = Path(f"{project.gravatar_cache_dir}/{gravatar_hash}.jpg")
with outfile.open('wb') as fp:
response.raw.decode_content = True
for chunk in response:
fp.write(chunk)
return gravatar_hash
def check_auth(username: str, password: str):
"""
Compares username and password from the settings file in a safe way.
Direct string comparison is vulnerable to timing attacks
https://sqreen.github.io/DevelopersSecurityBestPractices/timing-attack/python
:param username: username entered by the user
:param password: password entered by the user
:return: True if equal, False if not
"""
settings = Settings()
if compare_digest(username, settings.dashboard['username']) and \
compare_digest(password, settings.dashboard['password']):
return True
return False
def basic_login_required(f):
"""
Decorator for basic auth
"""
@wraps(f)
def wrapped_view(**kwargs):
auth = request.authorization
if not (auth and check_auth(auth.username, auth.password)):
return ('Unauthorized', 401, {
'WWW-Authenticate': 'Basic realm="Login Required"'
})
return f(**kwargs)
return wrapped_view
def export_location(location_id: int) -> bool:
"""
Exports the comments for the location after the comment was accepted
@ -141,12 +109,28 @@ def export_location(location_id: int) -> bool:
db.session.flush()
# Query
loc_query = db.session.query(TLocation).filter(TLocation.id_location == location_id).first()
location = db.session.query(TLocation).filter(TLocation.id_location == location_id).first()
if loc_query:
if location:
comments = db.session.query(TComments).filter(TComments.is_spam != True) \
.filter(TComments.is_published == True) \
.filter(TComments.location_id == loc_query.id_location)
.filter(TComments.location_id == location.id_location) \
.filter(TProjects.id_project == location.project_id) \
.all()
project = db.session.query(TProjects).filter(TProjects.id_project == location.project_id).first()
# Removes the last slash
path_loc = re_match(".*(?=/)", location.location)[0]
# Construct export path
jsonfile = Path(f"{project.output}/{path_loc}.json").absolute()
folder = jsonfile.parents[0]
# If there are no comments, do not export and remove empty file.
# The database is the point of trust.
if len(comments) == 0:
jsonfile.unlink(missing_ok=True)
return True
bundle = {
"comments": [],
@ -158,20 +142,14 @@ def export_location(location_id: int) -> bool:
continue
bundle['comments'].append(alchemy_query_to_dict(comment))
path_loc = re_match(".*(?=/)", loc_query.location)[0]
system = Settings().system
out = Path(f"{system['output']}/{path_loc}.json")
out = out.absolute()
folder = out.parents[0]
# Create folder if not exists and write file
folder.mkdir(parents=True, exist_ok=True)
with out.open('w') as fp:
with jsonfile.open('w') as fp:
json.dump(bundle, fp)
return True
except Exception as e:
# mail(f"export_comments has thrown an error: {str(e)}")
print(e, file=stderr)
return False

21
labertasche/mail/__init__.py

@ -13,10 +13,11 @@ from pathlib import Path
from platform import system
from smtplib import SMTP_SSL, SMTPHeloError, SMTPAuthenticationError, SMTPException
from ssl import create_default_context
from labertasche.settings import Settings
from validate_email import validate_email_or_fail
from secrets import token_urlsafe
from labertasche.models import TProjects
from labertasche.database import labertasche_db as db
from labertasche.settings import Settings
class mail:
@ -61,24 +62,30 @@ class mail:
except SMTPException as e:
print(f"SMTPException: {e}")
def send_confirmation_link(self, email):
def send_confirmation_link(self, email: str, name: str) -> tuple:
"""
Send confirmation link after entering a comment
:param email: The address to send the mail to
:param name: The name of the project
:return: A tuple with the confirmation token and the deletion token, in this order
"""
project = db.session.query(TProjects).filter(TProjects.name == name).first()
if not project:
return None, None
settings = Settings()
confirm_digest = token_urlsafe(48)
delete_digest = token_urlsafe(48)
confirm_url = f"{settings.system['web_url']}/comments/confirm/{confirm_digest}"
delete_url = f"{settings.system['web_url']}/comments/delete/{delete_digest}"
confirm_url = f"{settings.weburl}/comments/confirm/{confirm_digest}"
delete_url = f"{settings.weburl}/comments/delete/{delete_digest}"
txt_what = f"Hey there. You have made a comment on {settings.system['blog_url']}. Please confirm it by " \
txt_what = f"Hey there. You have made a comment on {project.blogurl}. Please confirm it by " \
f"copying this link into your browser:\n{confirm_url}\nIf you want to delete your comment for,"\
f"whatever reason, please use this link:\n{delete_url}"
html_what = f"Hey there. You have made a comment on {settings.system['blog_url']}.<br>Please confirm it by " \
html_what = f"Hey there. You have made a comment on {project.blogurl}.<br>Please confirm it by " \
f"clicking on this <a href='{confirm_url}'>link</a>.<br>"\
f"In case you want to delete your comment later, please click <a href='{delete_url}'>here</a>."\
f"<br><br>If you think this is in error or someone made this comment in your name, please "\

4
labertasche/models/t_projects.py

@ -19,8 +19,8 @@ class TProjects(db.Model):
# data
name = db.Column(db.Text, nullable=False, unique=True)
blogurl = db.Column(db.Text, nullable=False)
output = db.Column(db.Text, nullable=False)
blogurl = db.Column(db.Text, nullable=False, unique=False)
output = db.Column(db.Text, nullable=False, unique=True)
sendotp = db.Column(db.Boolean, nullable=False)
gravatar_cache = db.Column(db.Boolean, nullable=False)

145
labertasche/settings/__init__.py

@ -9,23 +9,164 @@
import yaml
from pathlib import Path
from platform import system
from shutil import copy
from hashlib import pbkdf2_hmac
from secrets import compare_digest
def hash_password(password, secret=None):
"""
Hashes the administrator password
:param password: The password to hash
:param secret: The site secret
:return: The hashed value as a hexadecimal string
"""
if not secret:
secret = Secret()
h = pbkdf2_hmac('sha512',
password=password.encode('utf8'),
salt=secret.key.encode('utf8'),
iterations=250000)
return h.hex()
class Settings:
def __init__(self):
file = Path("labertasche.yaml")
if system().lower() == "linux":
file = Path("/etc/labertasche/labertasche.yaml")
with file.open('r') as fp:
conf = yaml.safe_load(fp)
self.weburl = conf['system']['weburl']
self.cookie_domain = conf['system']['cookie_domain']
self.database_uri = conf['system']['database_uri']
self.debug = conf['system']['debug']
self.cookie_secure = conf['system']['cookie_secure']
class Secret:
def __init__(self):
file = Path(".secret")
if system().lower() == "linux":
file = Path("/etc/labertasche/.secret")
with file.open('r') as fp:
self.key = fp.readline()
class Smileys:
def __init__(self):
file = Path("smileys.yaml")
if system().lower() == "linux":
file = Path("/etc/labertasche/smileys.yaml")
with file.open('r') as fp:
conf = yaml.safe_load(fp)
self.smileys = conf['smileys']
class Credentials:
def __init__(self):
file = Path("credentials.yaml")
if system().lower() == "linux":
file = Path("/etc/labertasche/credentials.yaml")
with file.open('r') as fp:
conf = yaml.safe_load(fp)
self.username = conf['credentials']['username']
self.password = conf['credentials']['password']
def compare_password(self, userinput):
"""
Compares 2 passwords with one another
:param userinput: The input on the login page
:return: True if the passwords match, otherwise False
"""
secret = Secret()
hashed = pbkdf2_hmac('sha512',
password=userinput.encode('utf8'),
salt=secret.key.encode('utf8'),
iterations=250000)
return compare_digest(self.password, hashed.hex())
# deprecated, leave as is
class LegacySettings:
"""
Automatically loads the settings from /etc/ on Linux and same directory on other OS
"""
def __init__(self):
def __init__(self, use_backup: bool = False):
file = Path("labertasche.yaml")
if system().lower() == "linux":
file = Path("/etc/labertasche/labertasche.yaml")
# Use backup when conversion is done, this is used in db_v2.py
if use_backup:
file = file.with_suffix('.bak')
with file.open('r') as fp:
conf = yaml.safe_load(fp)
self.system = conf['system']
self.smileys = conf['smileys']
self.dashboard = conf['dashboard']
self.gravatar = conf['gravatar']
self.addons = conf['addons']
self.smileys = conf['smileys']
def convert_to_v2(self):
old = Path("labertasche.yaml")
if system().lower() == "linux":
old = Path("/etc/labertasche/labertasche.yaml")
systemvars = {
'system': {
'weburl': self.system['web_url'],
'cookie_domain': self.system['cookie_domain'],
'database_uri': self.system['database_uri'],
'debug': self.system['debug'],
'cookie_secure': self.system['cookie_secure']
}
}
credentials = {
'credentials': {
'username': self.dashboard['username'],
'password': hash_password(self.dashboard['password'], self.system['secret'])
}
}
smileys = {
'smileys': self.smileys
}
# backup old config
copy(old, old.with_suffix('.bak'))
# Write new config files
p_sys = Path('labertasche.yaml')
p_credentials = Path('credentials.yaml')
p_smileys = Path('smileys.yaml')
p_secret = Path('.secret')
if system().lower() == 'linux':
p_sys = '/etc/labertasche/' / p_sys
p_credentials = '/etc/labertasche/' / p_credentials
p_smileys = '/etc/labertasche/' / p_smileys
p_secret = '/etc/labertasche/' / p_secret
with p_sys.open('w') as fp:
yaml.dump(systemvars, fp)
with p_credentials.open('w') as fp:
yaml.dump(credentials, fp)
with p_smileys.open('w') as fp:
yaml.dump(smileys, fp)
with p_secret.open('w') as fp:
fp.write(self.system['secret'])

37
server.py

@ -6,13 +6,13 @@
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
import logging
# noinspection PyProtectedMember
from sqlalchemy.engine import Engine
from logging import getLogger, ERROR as LOGGING_ERROR
from flask import Flask, redirect, url_for
from flask_cors import CORS
from sqlalchemy import event, inspect
# noinspection PyProtectedMember
from sqlalchemy.engine import Engine
from labertasche.settings import Settings
from labertasche.settings import Settings, Secret
from labertasche.database import labertasche_db
from labertasche.blueprints import bp_comments, bp_login, bp_dashboard, bp_jsconnector, bp_dbupgrades
from labertasche.helper import User
@ -21,28 +21,29 @@ from datetime import timedelta
# Load settings
settings = Settings()
secret = Secret()
# Flask App
laberflask = Flask(__name__)
laberflask.config.update(dict(
SESSION_COOKIE_DOMAIN=settings.system['cookie_domain'],
SESSION_COOKIE_SECURE=settings.system['cookie_secure'],
REMEMBER_COOKIE_SECURE=settings.system['cookie_secure'],
SESSION_COOKIE_DOMAIN=settings.cookie_domain,
SESSION_COOKIE_SECURE=settings.cookie_secure,
REMEMBER_COOKIE_SECURE=settings.cookie_secure,
REMEMBER_COOKIE_DURATION=timedelta(days=7),
REMEMBER_COOKIE_HTTPONLY=True,
REMEMBER_COOKIE_REFRESH_EACH_REQUEST=True,
DEBUG=settings.system['debug'],
SECRET_KEY=settings.system['secret'],
TEMPLATES_AUTO_RELOAD=settings.system['debug'],
SQLALCHEMY_DATABASE_URI=settings.system['database_uri'],
DEBUG=settings.debug,
SECRET_KEY=secret.key,
TEMPLATES_AUTO_RELOAD=settings.debug,
SQLALCHEMY_DATABASE_URI=settings.database_uri,
SQLALCHEMY_TRACK_MODIFICATIONS=False
))
# Mark secret for deletion
del secret
# CORS
CORS(laberflask, resources={r"/comments": {"origins": settings.system['blog_url']},
r"/api": {"origins": settings.system['web_url']},
r"/dashboard": {"origins": settings.system['web_url']},
})
cors = CORS(laberflask)
# Import blueprints
laberflask.register_blueprint(bp_comments)
@ -52,8 +53,8 @@ laberflask.register_blueprint(bp_jsconnector)
laberflask.register_blueprint(bp_dbupgrades)
# Disable Werkzeug's verbosity during development
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
log = getLogger('werkzeug')
log.setLevel(LOGGING_ERROR)
# Set up login manager
loginmgr = LoginManager(laberflask)
@ -87,7 +88,7 @@ def login_invalid():
# noinspection PyUnusedLocal
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
if settings.system["database_uri"][0:6] == 'sqlite':
if settings.database_uri[0:6] == 'sqlite':
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.close()

368
static/js/dashboard.js

@ -5,6 +5,48 @@
// # * _license : This project is under MIT License
// # *********************************************************************************/
async function get(partial, callback) {
await fetch(window.location.protocol + "//" + window.location.host + partial,
{
mode: "cors",
headers: {
'Access-Control-Allow-Origin': window.location.host,
'Accept': 'application/json',
'Content-Type': 'application/json'
},
method: "GET"
})
.then(async function (response) {
let result = await response.json();
callback(result);
})
.catch(function (exc) {
console.log(exc);
return null;
})
}
async function post(partial, stringified_json, callback) {
await fetch(window.location.protocol + "//" + window.location.host + partial,
{
mode: "cors",
headers: {
'Access-Control-Allow-Origin': window.location.host,
'Accept': 'application/json',
'Content-Type': 'application/json'
},
method: "POST",
body: stringified_json
})
.then(async function (response) {
let result = await response.json();
callback(result);
})
.catch(function (exc) {
console.log(exc);
})
}
// ------------------------------------------------------
// Called when search for mail addresses in manage mail
// ------------------------------------------------------
@ -24,74 +66,15 @@ function dashboard_mailsearch(search_txt)
}
// ------------------------------------------------------
// Called when a new project is created,
// posts it to the server
// Deletes a project from the db
// ------------------------------------------------------
function new_project_save() {
let modal_ok = document.getElementById('modal-ok');
let modal_cancel = document.getElementById('modal-cancel');
let short_help_short = document.getElementById('new-project-too-short');
let short_help_invalid = document.getElementById('new-project-invalid-name');
let name = document.getElementById('project-name').value
short_help_short.classList.add('is-hidden');
short_help_invalid.classList.add('is-hidden');
// Validate input
if (name.length === 0) {
short_help_short.classList.remove('is-hidden');
return false;
}
if (/^\w+$/.test(name) === false){
short_help_invalid.classList.remove('is-hidden');
return false;
}
modal_ok.classList.add('is-loading');
modal_cancel.classList.add('is-hidden');
fetch(window.location.protocol + "//" + window.location.host + '/api/project/new',
{
mode: "cors",
headers: {
'Access-Control-Allow-Origin': '*',
'Accept': 'application/json',
'Content-Type': 'application/json'
},
method: "POST",
// use real location
body: JSON.stringify({
"name": name
})
})
.then(async function (response) {
let result = await response.json();
result = result['status'];
modal_ok.classList.remove('is-loading');
modal_cancel.classList.remove('is-hidden');
if (result === "ok"){
hide_modal('modal-new-project');
window.location.reload(true);
}
if (result === "too-short"){
short_help_short.classList.remove('is-hidden');
}
if (result === "invalid-name"){
short_help_invalid.classList.remove('is-hidden');
}
})
.catch(function (exc) {
console.log(exc);
})
}
function project_delete()
async function project_delete()
{
let modal = document.getElementById('modal-project-delete');
let modal_ok = document.getElementById('modal-delete-ok');
let modal_cancel = document.getElementById('modal-delete-cancel');
const project = modal.dataset.project;
console.log("Project: " + project);
const project = modal.dataset.name;
if (project === null || project.length === 0){
console.log("Couldn't find a valid dataset");
return;
@ -99,58 +82,253 @@ function project_delete()
modal_ok.classList.add('is-loading');
modal_cancel.classList.add('is-hidden');
fetch(window.location.protocol + "//" + window.location.host + '/api/project/delete/' + project,
{
mode: "cors",
headers: {
'Access-Control-Allow-Origin': '*',
'Accept': 'application/json',
'Content-Type': 'application/json'
},
method: "GET"
})
.then(async function (response) {
let result = await response.json();
result = result['status'];
modal_ok.classList.remove('is-loading');
modal_cancel.classList.remove('is-hidden');
if (result === "ok") {
hide_modal('modal-project-delete');
window.location.reload(true);
}
await get('/api/project/delete/' + project, function(result){
modal_ok.classList.remove('is-loading');
modal_cancel.classList.remove('is-hidden');
if (result === "ok") {
console.log(result);
})
.catch(function (exc) {
console.log(exc);
})
}
})
modal.classList.remove('is-active');
window.location.reload(true);
}
// ------------------------------------------------------
// Hides any modal
// ------------------------------------------------------
function hide_modal(id_name)
function hide_modal(id_name, redirect=null)
{
let el = document.getElementById(id_name);
el.classList.remove("is-active");
if(redirect != null){
window.location = redirect;
}
}
// ------------------------------------------------------
// Shows any modal
// Shows any modal and attach project name
// ------------------------------------------------------
function show_modal(id_name)
function show_modal(id_name, proj_name)
{
let el = document.getElementById(id_name);
el.classList.add("is-active");
el.setAttribute('data-name', proj_name);
}
// ------------------------------------------------------
// Shows any modal
// Shows edit/new modal
// ------------------------------------------------------
function show_modal_with_project(id_name, proj_name)
async function show_modal_with_project(id_name, proj_name)
{
let el = document.getElementById(id_name);
el.classList.add("is-active");
el.setAttribute('data-project', proj_name)
// Get title element
let title = document.getElementById('modal-title');
// Get Dialog
let modal = document.getElementById(id_name);
if (proj_name){
// Get Data
await get('/api/project/get/' + proj_name,
function(r){
document.getElementById('edit-project-blog-url').value = r['blogurl'];
document.getElementById('edit-project-output').value = r['output'];
document.getElementById('edit-project-gravatar-cache').checked = r['gravatar_cache'];
document.getElementById('edit-project-gravatar-cache-dir').value = r['gravatar_cache_dir'];
document.getElementById('edit-project-gravatar-size').value = r['gravatar_size'];
document.getElementById('edit-project-send-otp').checked = r['sendotp'];
document.getElementById('edit-project-addons-smileys').checked = r['addon_smileys'];
});
// Set project name
let proj_el = document.getElementById('edit-project-name');
proj_el.value = proj_name
// Set project name
title.innerText = "Edit project '" + proj_name + "'";
// Make active
modal.classList.add("is-active");
// Edit mode
modal.setAttribute('data-mode', 'edit');
modal.setAttribute('data-name', proj_name);
}
if (proj_name == null){
// Set project name
title.innerText = "New Project";
// Reset fields, needed when user pressed cancel on edit modal
document.getElementById('edit-project-name').value = "";
document.getElementById('edit-project-blog-url').value = "";
document.getElementById('edit-project-output').value = "";
document.getElementById('edit-project-gravatar-cache').checked = true;
document.getElementById('edit-project-gravatar-cache-dir').value = "";
document.getElementById('edit-project-gravatar-size').value = 256;
document.getElementById('edit-project-send-otp').checked = true;
document.getElementById('edit-project-addons-smileys').checked = true;
// Edit mode
modal.setAttribute('data-mode', 'new');
// Make active
modal.classList.add("is-active");
}
}
async function save_project_settings(id_name)
{
// Spin the tea cups
let btn = document.getElementById('modal-save-ok');
btn.classList.add('is-loading');
// Get modal
let modal = document.getElementById(id_name);
// Get field data
let json_data = {
"name": document.getElementById('edit-project-name').value,
"blogurl": document.getElementById('edit-project-blog-url').value,
"output": document.getElementById('edit-project-output').value,
"gravatar_cache": document.getElementById('edit-project-gravatar-cache').checked,
"gravatar_cache_dir": document.getElementById('edit-project-gravatar-cache-dir').value,
"gravatar_size": document.getElementById('edit-project-gravatar-size').value,
"sendotp": document.getElementById('edit-project-send-otp').checked,
"addon_smileys": document.getElementById('edit-project-addons-smileys').checked
}
if (modal.dataset.mode === "edit"){
let old_name = modal.dataset.name;
await post('/api/project/edit/' + old_name, JSON.stringify(json_data), function(result){
let error = document.getElementById('modal-edit-error-messages')
error.innerText = ''
if (result['status'] === 'too-short'){
error.innerText = "A required field has been left empty!"
return;
}
if (result['status'] === 'invalid-project-name') {
error.innerText = "The project name is not valid. Please only use alphanumeric characters!"
return;
}
if (result['status'] === 'project-exists') {
error.innerText = "A project with this name already exists!"
return;
}
if (result['status'] === 'invalid-blog-url') {
error.innerText = "The blog-url is invalid!"
return;
}
if (result['status'] === 'invalid-path-output') {
error.innerText = "This output path does not exist!"
return;
}
if (result['status'] === 'invalid-path-cache') {
error.innerText = "The cache path does not exist!"
return;
}
if (result['status'] === 'exception') {
error.innerText = "There was an unexpected exception. Please report this to contact@tuxstash.de:"
error.innerText += result['msg']
return;
}
// Reset button
btn.classList.remove('is-loading');
window.location.reload(true);
})
}
if (modal.dataset.mode === 'new'){
await post('/api/project/new', JSON.stringify(json_data), function(result){
let error = document.getElementById('modal-edit-error-messages')
error.innerText = '';
console.log(result['status']);
if (result['status'] === 'too-short'){
error.innerText = "A required field has been left empty!";
btn.classList.remove('is-loading');
return;
}
if (result['status'] === 'invalid-project-name') {
error.innerText = "The project name is not valid. Please only use alphanumeric characters!";
btn.classList.remove('is-loading');
return;
}
if (result['status'] === 'project-exists') {
error.innerText = "A project with this name already exists!";
btn.classList.remove('is-loading');
return;
}
if (result['status'] === 'invalid-blog-url') {
error.innerText = "The blog-url is invalid!";
btn.classList.remove('is-loading');
return;
}
if (result['status'] === 'invalid-path-output') {
error.innerText = "This output path does not exist!";
btn.classList.remove('is-loading');
return;
}
if (result['status'] === 'invalid-path-cache') {
error.innerText = "The cache path does not exist!";
btn.classList.remove('is-loading');
return;
}
if (result['status'] === 'exception') {
error.innerText = "There was an unexpected exception. Please report this to contact@tuxstash.de:";
error.innerText += result['msg'];
btn.classList.remove('is-loading');
return;
}
// Reset button
btn.classList.remove('is-loading');
window.location.reload(true);
})
}
}
// ------------------------------------------------------
// Disables inputs when gravatar caching is disabled.
// ------------------------------------------------------
function toggle_gravatar_settings(chkbx)
{
let cache = document.getElementById('edit-project-gravatar-cache-dir');
let size = document.getElementById('edit-project-gravatar-size');
if(!chkbx.checked){
cache.setAttribute('disabled', '');
size.setAttribute('disabled', '');
cache.value = "disabled";
size.value = "disabled";
}
else{
cache.removeAttribute('disabled');
size.removeAttribute('disabled');
cache.value = "";
size.value = "256";
}
}
// ------------------------------------------------------
// Exports all comments
// ------------------------------------------------------
async function export_all_comments(btn)
{
btn.classList.add('is-loading');
let proj_name = document.getElementById('modal-comments-export').dataset.name;
await get('/api/comment-export-all/' + proj_name, function(result){
if (result['status'] === 'ok'){
hide_modal('modal-comments-export');
}
if (result['status'] === 'not-found'){
// Redirect to error
hide_modal('modal-comments-export', '?error=404');
}
btn.classList.remove('is-loading');
})
}

8
templates/base.html

@ -4,14 +4,6 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="description" content="labertasche comment system dashboard">
<!-- Preload -->
<link rel="preload" href="/static/css/open-sans-v18-latin-regular.woff2" as="font" type="font/woff2" crossorigin="anonymous" media="all">
<link rel="preload" href="/static/css/materialdesignicons-webfont.woff2" as="font" type="font/woff2" crossorigin="anonymous" media="all">
<link rel="preload" href="/static/css/labertasche.css" as="style" crossorigin="anonymous" media="screen">
<link rel="preload" href="/static/css/materialdesignicons.min.css" as="style" crossorigin="anonymous" media="screen">
<link rel="preload" href="/static/css/Chart.min.css" as="style" media="screen">
<link rel="stylesheet" href="/static/css/labertasche.css" media="screen">
<link rel="stylesheet" href="/static/css/materialdesignicons.min.css" media="screen">
<link rel="stylesheet" href="/static/css/Chart.min.css" media="screen">

10
templates/login.html

@ -4,15 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="description" content="labertasche comment system dashboard">
<link rel="preconnect" href="https://cdn.materialdesignicons.com/">
<!-- Preload -->
<link rel="preload" href="/static/css/open-sans-v18-latin-regular.woff2" as="font" type="font/woff2" crossorigin="anonymous" media="all">
<link rel="preload" href="https://cdn.materialdesignicons.com/5.4.55/css/materialdesignicons.min.css" as="style">
<link rel="preload" as="style" href="/static/css/labertasche.css">
<link rel="stylesheet" href="https://cdn.materialdesignicons.com/5.4.55/css/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/css/labertasche.css" media="screen">
<link rel="stylesheet" href="/static/css/materialdesignicons.min.css" media="screen">
<link rel="stylesheet" href="/static/css/Chart.min.css" media="screen">
<title>labertasche Dashboard</title>
</head>

22
templates/modals/comments-export-all.html

@ -0,0 +1,22 @@
<div class="modal" id="modal-comments-export">