diff --git a/.gitignore b/.gitignore index 1bba46b..54b86b5 100644 --- a/.gitignore +++ b/.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 diff --git a/js/labertasche.js b/js/labertasche.js index 50c2e6f..4a6c3cd 100644 --- a/js/labertasche.js +++ b/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"){ diff --git a/labertasche/blueprints/bp_comments/__init__.py b/labertasche/blueprints/bp_comments/__init__.py index a24bd0b..9dda7c4 100644 --- a/labertasche/blueprints/bp_comments/__init__.py +++ b/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("//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/", methods=['GET']) +@bp_comments.route("/confirm//", 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/", methods=['GET']) +@bp_comments.route("/delete//", 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") diff --git a/labertasche/blueprints/bp_jsconnector/comments.py b/labertasche/blueprints/bp_jsconnector/comments.py index 963c3b4..bd994b2 100644 --- a/labertasche/blueprints/bp_jsconnector/comments.py +++ b/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/', 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) diff --git a/labertasche/blueprints/bp_jsconnector/projects.py b/labertasche/blueprints/bp_jsconnector/projects.py index d658ab9..582bccd 100644 --- a/labertasche/blueprints/bp_jsconnector/projects.py +++ b/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/', methods=['POST']) +@bp_jsconnector.route('/project/edit/', 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/', methods=['GET']) +@bp_jsconnector.route('/project/delete/', 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/', 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/', 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) diff --git a/labertasche/blueprints/bp_login/__init__.py b/labertasche/blueprints/bp_login/__init__.py index b164014..9b7a824 100644 --- a/labertasche/blueprints/bp_login/__init__.py +++ b/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')) diff --git a/labertasche/blueprints/bp_upgrades/db_v2.py b/labertasche/blueprints/bp_upgrades/db_v2.py index e85a03b..1c1ae7b 100644 --- a/labertasche/blueprints/bp_upgrades/db_v2.py +++ b/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 diff --git a/labertasche/helper/__init__.py b/labertasche/helper/__init__.py index cc5766a..28bfb21 100644 --- a/labertasche/helper/__init__.py +++ b/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 diff --git a/labertasche/mail/__init__.py b/labertasche/mail/__init__.py index 3ac951f..a49f9fd 100644 --- a/labertasche/mail/__init__.py +++ b/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']}.
Please confirm it by " \ + html_what = f"Hey there. You have made a comment on {project.blogurl}.
Please confirm it by " \ f"clicking on this link.
"\ f"In case you want to delete your comment later, please click here."\ f"

If you think this is in error or someone made this comment in your name, please "\ diff --git a/labertasche/models/t_projects.py b/labertasche/models/t_projects.py index 1ec9b63..a1fd398 100644 --- a/labertasche/models/t_projects.py +++ b/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) diff --git a/labertasche/settings/__init__.py b/labertasche/settings/__init__.py index 6fba975..03f886c 100644 --- a/labertasche/settings/__init__.py +++ b/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']) diff --git a/server.py b/server.py index 71cea94..de90ce4 100644 --- a/server.py +++ b/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() diff --git a/static/js/dashboard.js b/static/js/dashboard.js index d445030..7cc14a2 100644 --- a/static/js/dashboard.js +++ b/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'); + }) } diff --git a/templates/base.html b/templates/base.html index 725e907..303804a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,14 +4,6 @@ - - - - - - - - diff --git a/templates/login.html b/templates/login.html index 261eeec..1f60757 100644 --- a/templates/login.html +++ b/templates/login.html @@ -4,15 +4,9 @@ - - - - - - - - + + labertasche Dashboard diff --git a/templates/modals/comments-export-all.html b/templates/modals/comments-export-all.html new file mode 100644 index 0000000..de3512c --- /dev/null +++ b/templates/modals/comments-export-all.html @@ -0,0 +1,22 @@ + diff --git a/templates/modals/project-delete.html b/templates/modals/project-delete.html new file mode 100644 index 0000000..9d51f01 --- /dev/null +++ b/templates/modals/project-delete.html @@ -0,0 +1,21 @@ + diff --git a/templates/modals/project_edit.html b/templates/modals/project_edit.html new file mode 100644 index 0000000..c78bb38 --- /dev/null +++ b/templates/modals/project_edit.html @@ -0,0 +1,109 @@ + diff --git a/templates/modals/project_not_found.html b/templates/modals/project_not_found.html new file mode 100644 index 0000000..b3ed5d7 --- /dev/null +++ b/templates/modals/project_not_found.html @@ -0,0 +1,24 @@ + diff --git a/templates/project-list.html b/templates/project-list.html index 48acb86..d3844bb 100644 --- a/templates/project-list.html +++ b/templates/project-list.html @@ -36,10 +36,11 @@ @@ -80,22 +81,24 @@ @@ -103,142 +106,14 @@ {% endfor %} - - - - + {% include "modals/project_not_found.html" %} + {% include "modals/project-delete.html" %} + {% include "modals/project_edit.html" %} + {% include "modals/comments-export-all.html"%} {% endblock %} {% block javascript %} tippy('[data-tippy-content]', { allowHTML: true, delay: 500 }); - - weburl = document.getElementById('edit-project-web-url') - weburl.value = window.location.host; {% endblock %}