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 4 years ago
parent 194ffc754b
commit ad8f68cdd8
  1. 7
      .gitignore
  2. 1
      js/labertasche.js
  3. 64
      labertasche/blueprints/bp_comments/__init__.py
  4. 22
      labertasche/blueprints/bp_jsconnector/comments.py
  5. 137
      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. 360
      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 vendored

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

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

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

@ -7,12 +7,12 @@
# * _license : This project is under MIT License # * _license : This project is under MIT License
# *********************************************************************************/ # *********************************************************************************/
from . import bp_jsconnector 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_login import login_required
from flask_cors import cross_origin from flask_cors import cross_origin
from labertasche.database import labertasche_db as db from labertasche.database import labertasche_db as db
from labertasche.helper import export_location from labertasche.helper import export_location, get_id_from_project_name
from labertasche.models import TComments, TEmail from labertasche.models import TComments, TEmail, TLocation
# This file contains the routes for the manage comments menu point. # This file contains the routes for the manage comments menu point.
# They are called via GET # They are called via GET
@ -112,3 +112,19 @@ def api_comment_block_mail(comment_id):
return redirect(request.referrer) 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)

@ -13,9 +13,57 @@ from flask_cors import cross_origin
from labertasche.database import labertasche_db as db from labertasche.database import labertasche_db as db
from labertasche.helper import get_id_from_project_name from labertasche.helper import get_id_from_project_name
from labertasche.models import TProjects, TComments, TEmail, TLocation from labertasche.models import TProjects, TComments, TEmail, TLocation
from validators import url as validate_url
from pathlib import Path
import re 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() @cross_origin()
@bp_jsconnector.route("/project/new", methods=['POST']) @bp_jsconnector.route("/project/new", methods=['POST'])
@login_required @login_required
@ -25,58 +73,64 @@ def api_create_project():
:return: A string with an error code and 'ok' as string on success. :return: A string with an error code and 'ok' as string on success.
""" """
# TODO: Project name exists? response = validate_project(request.json)
name = request.json['name'] if response is not None:
return response
if not len(name): try:
return make_response(jsonify(status='too-short'), 400) db.session.add(TProjects(**request.json))
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() 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) return make_response(jsonify(status='ok'), 200)
@cross_origin() @cross_origin()
@bp_jsconnector.route('project/edit/<name>', methods=['POST']) @bp_jsconnector.route('/project/edit/<name>', methods=['POST'])
@login_required @login_required
def api_edit_project_name(name: str): def api_edit_project_name(name: str):
""" """
Renames the project. 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. :return: A string with an error code and 'ok' as string on success.
""" """
# TODO: Project name exists? response = validate_project(request.json)
new_name = request.json['name'] if response is not None:
return response
if not len(new_name): try:
return make_response(jsonify(status='too-short'), 400) project = db.session.query(TProjects).filter(TProjects.name == name).first()
if not re.match('^\\w+$', new_name): setattr(project, "id_project", project.id_project)
return make_response(jsonify(status='invalid-name'), 400) setattr(project, "name", request.json['name'])
setattr(project, "blogurl", request.json['blogurl'].strip())
proj_id = get_id_from_project_name(name) setattr(project, "output", request.json['output'].strip())
project = db.session.query(TProjects).filter(TProjects.id_project == proj_id) setattr(project, "sendotp", request.json['sendotp'])
setattr(project, 'name', new_name) setattr(project, "gravatar_cache", request.json['gravatar_cache'])
db.session.upate(project) 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() 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) return make_response(jsonify(status='ok'), 200)
@cross_origin() @cross_origin()
@bp_jsconnector.route('project/delete/<project>', methods=['GET']) @bp_jsconnector.route('/project/delete/<name>', methods=['GET'])
@login_required @login_required
def api_delete_project(project: str): def api_delete_project(name: str):
""" """
Deletes a project from the database and all associated data 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. :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: if proj_id == -1:
return make_response(jsonify(status='not-found'), 400) 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='exception'), 400)
return make_response(jsonify(status='ok'), 200) 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,7 +8,9 @@
# *********************************************************************************/ # *********************************************************************************/
from flask import Blueprint, render_template, request, redirect, url_for from flask import Blueprint, render_template, request, redirect, url_for
from flask_cors import cross_origin 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 from flask_login import login_user, current_user, logout_user
# Blueprint # Blueprint
@ -30,7 +32,9 @@ def login():
username = request.form['username'] username = request.form['username']
password = request.form['password'] 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) login_user(User(0), remember=True)
return redirect(url_for('bp_dashboard.dashboard_project_list')) return redirect(url_for('bp_dashboard.dashboard_project_list'))

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

@ -8,21 +8,19 @@
# *********************************************************************************/ # *********************************************************************************/
import datetime import datetime
import json 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 hashlib import md5
from flask import request
from flask_login import UserMixin from flask_login import UserMixin
from secrets import compare_digest
from pathlib import Path from pathlib import Path
from sys import stderr from sys import stderr
from re import match as re_match 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 User(UserMixin):
"""
Class for flask-login, which represents a user
"""
def __init__(self, user_id): def __init__(self, user_id):
self.id = user_id self.id = user_id
@ -37,7 +35,7 @@ def is_valid_json(j):
try: try:
json.dumps(j) json.dumps(j)
return True return True
except json.JSONDecodeError as e: except json.JSONDecodeError:
print("not valid json") print("not valid json")
return False return False
@ -77,60 +75,30 @@ def alchemy_query_to_dict(obj):
# Come on, it's a mail hash, don't complain # Come on, it's a mail hash, don't complain
# noinspection InsecureHash # noinspection InsecureHash
def check_gravatar(email: str): def check_gravatar(email: str, name: str):
""" """
Builds the gravatar email hash, which uses md5. Builds the gravatar email hash, which uses md5.
You may use ?size=128 for example to dictate size in the final template. You may use ?size=128 for example to dictate size in the final template.
:param email: the email to use for the hash :param email: the email to use for the hash
:param name: The project name
:return: the gravatar url of the image :return: the gravatar url of the image
""" """
settings = Settings() from requests import get
options = settings.gravatar project = db.session.query(TProjects).filter(TProjects.name == name).first()
gravatar_hash = md5(email.strip().lower().encode("utf8")).hexdigest() gravatar_hash = md5(email.strip().lower().encode("utf8")).hexdigest()
if options['cache']:
url = f"https://www.gravatar.com/avatar/{gravatar_hash}?s={options['size']}" if project.gravatar_cache:
response = requests.get(url) url = f"https://www.gravatar.com/avatar/{gravatar_hash}?s={project.gravatar_size}"
response = get(url)
if response.ok: 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: with outfile.open('wb') as fp:
response.raw.decode_content = True response.raw.decode_content = True
for chunk in response: for chunk in response:
fp.write(chunk) fp.write(chunk)
return gravatar_hash 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: def export_location(location_id: int) -> bool:
""" """
Exports the comments for the location after the comment was accepted 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() db.session.flush()
# Query # 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) \ comments = db.session.query(TComments).filter(TComments.is_spam != True) \
.filter(TComments.is_published == 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 = { bundle = {
"comments": [], "comments": [],
@ -158,20 +142,14 @@ def export_location(location_id: int) -> bool:
continue continue
bundle['comments'].append(alchemy_query_to_dict(comment)) bundle['comments'].append(alchemy_query_to_dict(comment))
path_loc = re_match(".*(?=/)", loc_query.location)[0] # Create folder if not exists and write file
system = Settings().system
out = Path(f"{system['output']}/{path_loc}.json")
out = out.absolute()
folder = out.parents[0]
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
with out.open('w') as fp: with jsonfile.open('w') as fp:
json.dump(bundle, fp) json.dump(bundle, fp)
return True return True
except Exception as e: except Exception as e:
# mail(f"export_comments has thrown an error: {str(e)}")
print(e, file=stderr) print(e, file=stderr)
return False return False

@ -13,10 +13,11 @@ from pathlib import Path
from platform import system from platform import system
from smtplib import SMTP_SSL, SMTPHeloError, SMTPAuthenticationError, SMTPException from smtplib import SMTP_SSL, SMTPHeloError, SMTPAuthenticationError, SMTPException
from ssl import create_default_context from ssl import create_default_context
from labertasche.settings import Settings
from validate_email import validate_email_or_fail from validate_email import validate_email_or_fail
from secrets import token_urlsafe 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: class mail:
@ -61,24 +62,30 @@ class mail:
except SMTPException as e: except SMTPException as e:
print(f"SMTPException: {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 Send confirmation link after entering a comment
:param email: The address to send the mail to :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 :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() settings = Settings()
confirm_digest = token_urlsafe(48) confirm_digest = token_urlsafe(48)
delete_digest = token_urlsafe(48) delete_digest = token_urlsafe(48)
confirm_url = f"{settings.system['web_url']}/comments/confirm/{confirm_digest}" confirm_url = f"{settings.weburl}/comments/confirm/{confirm_digest}"
delete_url = f"{settings.system['web_url']}/comments/delete/{delete_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"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}" 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"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"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 "\ f"<br><br>If you think this is in error or someone made this comment in your name, please "\

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

@ -9,23 +9,164 @@
import yaml import yaml
from pathlib import Path from pathlib import Path
from platform import system 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: 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 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") file = Path("labertasche.yaml")
if system().lower() == "linux": if system().lower() == "linux":
file = Path("/etc/labertasche/labertasche.yaml") 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: with file.open('r') as fp:
conf = yaml.safe_load(fp) conf = yaml.safe_load(fp)
self.system = conf['system'] self.system = conf['system']
self.smileys = conf['smileys']
self.dashboard = conf['dashboard'] self.dashboard = conf['dashboard']
self.gravatar = conf['gravatar'] self.gravatar = conf['gravatar']
self.addons = conf['addons'] 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'])

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

@ -5,6 +5,48 @@
// # * _license : This project is under MIT License // # * _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 // 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, // Deletes a project from the db
// posts it to the server
// ------------------------------------------------------ // ------------------------------------------------------
function new_project_save() { async function project_delete()
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()
{ {
let modal = document.getElementById('modal-project-delete'); let modal = document.getElementById('modal-project-delete');
let modal_ok = document.getElementById('modal-delete-ok'); let modal_ok = document.getElementById('modal-delete-ok');
let modal_cancel = document.getElementById('modal-delete-cancel'); let modal_cancel = document.getElementById('modal-delete-cancel');
const project = modal.dataset.project; const project = modal.dataset.name;
console.log("Project: " + project);
if (project === null || project.length === 0){ if (project === null || project.length === 0){
console.log("Couldn't find a valid dataset"); console.log("Couldn't find a valid dataset");
return; return;
@ -99,58 +82,253 @@ function project_delete()
modal_ok.classList.add('is-loading'); modal_ok.classList.add('is-loading');
modal_cancel.classList.add('is-hidden'); modal_cancel.classList.add('is-hidden');
fetch(window.location.protocol + "//" + window.location.host + '/api/project/delete/' + project,
{ await get('/api/project/delete/' + project, function(result){
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_ok.classList.remove('is-loading');
modal_cancel.classList.remove('is-hidden'); modal_cancel.classList.remove('is-hidden');
if (result === "ok") { if (result === "ok") {
hide_modal('modal-project-delete');
window.location.reload(true);
}
console.log(result); console.log(result);
}
}) })
.catch(function (exc) { modal.classList.remove('is-active');
console.log(exc); window.location.reload(true);
})
} }
// ------------------------------------------------------ // ------------------------------------------------------
// Hides any modal // Hides any modal
// ------------------------------------------------------ // ------------------------------------------------------
function hide_modal(id_name) function hide_modal(id_name, redirect=null)
{ {
let el = document.getElementById(id_name); let el = document.getElementById(id_name);
el.classList.remove("is-active"); 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); let el = document.getElementById(id_name);
el.classList.add("is-active"); 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); // Get title element
el.classList.add("is-active"); let title = document.getElementById('modal-title');
el.setAttribute('data-project', proj_name)
// 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');
})
} }

@ -4,14 +4,6 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes"> <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"> <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/labertasche.css" media="screen">
<link rel="stylesheet" href="/static/css/materialdesignicons.min.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"> <link rel="stylesheet" href="/static/css/Chart.min.css" media="screen">

@ -4,15 +4,9 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes"> <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"> <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/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> <title>labertasche Dashboard</title>
</head> </head>

@ -0,0 +1,22 @@
<div class="modal" id="modal-comments-export">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-warning">
<p class="modal-card-title has-text-black is-uppercase">Export all comments</p>
<button onclick="hide_modal('modal-comments-export')" class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body has-text-white bg-deepmatte">
<p class="block">This will export all comments of this project to all locations.
Usually this is not needed, but can be helpful, if you have imported backups or similar.</p>
<p class="block has-text-danger">Do you wish to proceed?</p>
</section>
<footer class="modal-card-foot bg-deepmatte">
<button id="modal-ok" onclick="export_all_comments(this);" class="button is-success">
OK
</button>
<button id="modal-cancel" onclick="hide_modal('modal-comments-export')" class="button is-info">
CANCEL
</button>
</footer>
</div>
</div>

@ -0,0 +1,21 @@
<div class="modal" id="modal-project-delete">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-warning">
<p class="modal-card-title has-text-black">WARNING!</p>
<button onclick="hide_modal('modal-project-delete')" class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body has-text-black">
You are about to delete a project. All associated data will be unrecoverably lost!
Please perform a manual sql dump if you would like to retain that data.
</section>
<footer class="modal-card-foot">
<button id="modal-delete-ok" onclick="project_delete()" class="button is-danger">
OK
</button>
<button id="modal-delete-cancel" onclick="hide_modal('modal-project-delete')" class="button is-success">
CANCEL
</button>
</footer>
</div>
</div>

@ -0,0 +1,109 @@
<div class="modal" id="modal-project-edit">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-warning">
<p id="modal-title" class="modal-card-title has-text-black">Edit Project</p>
<button onclick="hide_modal('modal-project-edit')" class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body has-text-black bg-deepmatte">
<form>
<div class="field">
<div class="control has-icons-left"
data-tippy-content="Please select an unique name for your project.">
<input class="input" id="edit-project-name"
name="edit-project-name" type="text">
<span class="icon is-small is-left">
<i class="mdi mdi-24px mdi-help-rhombus-outline"></i>
</span>
</div>
<label class="label help has-text-white" for="edit-project-name">
Project Name
</label>
</div>
<div class="field">
<div class="control has-icons-left"
data-tippy-content="URL of your hugo site, e.g. https://example.com">
<input class="input" id="edit-project-blog-url" name="edit-project-blog-url" type="text">
<span class="icon is-small is-left">
<i class="mdi mdi-24px mdi-help-rhombus-outline"></i>
</span>
</div>
<label class="label help has-text-white" for="edit-project-blog-url">
URL of your Hugo site for this project
</label>
</div>
<div class="field">
<div class="control has-icons-left"
data-tippy-content="The path to the data directory of your Hugo installation.">
<input class="input" id="edit-project-output" name="edit-project-output" type="text">
<span class="icon is-small is-left">
<i class="mdi mdi-24px mdi-help-rhombus-outline"></i>
</span>
</div>
<label class="label help has-text-white" for="edit-project-output">Output Directory</label>
</div>
<div class="field">
<div class="control">
<label class="checkbox help has-text-white" for="edit-project-gravatar-cache">
<input id="edit-project-gravatar-cache" class="checkbox" type="checkbox"
name="edit-project-gravatar-cache" checked onclick="toggle_gravatar_settings(this);">
Cache Gravatar images?
</label>
</div>
</div>
<div class="field">
<div class="control has-icons-left"
data-tippy-content="The directory where to save the gravatar images. Should be a full path.">
<input class="input" id="edit-project-gravatar-cache-dir" name="edit-project-gravatar-cache-dir" type="text">
<span class="icon is-small is-left">
<i class="mdi mdi-24px mdi-help-rhombus-outline"></i>
</span>
</div>
<label class="label help has-text-white" for="edit-project-gravatar-cache-dir">
Gravatar Cache Directory
</label>
</div>
<div class="field">
<div class="control has-icons-left"
data-tippy-content="The numeric size of the images to download. Must be a power of 2.">
<input class="input" id="edit-project-gravatar-size" name="edit-project-gravatar-size" type="text" value="256">
<span class="icon is-small is-left">
<i class="mdi mdi-24px mdi-help-rhombus-outline"></i>
</span>
</div>
<label class="label help has-text-white" for="edit-project-gravatar-size">
Gravatar image size
</label>
</div>
<div class="field">
<div class="control">
<label class="checkbox help has-text-white" for="edit-project-send-otp">
<input id="edit-project-send-otp" class="checkbox" type="checkbox"
name="edit-project-send-otp" checked>
Send OTP to publish?
</label>
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox help has-text-white" for="edit-project-addons-smileys">
<input id="edit-project-addons-smileys" class="checkbox" type="checkbox"
name="edit-project-addons-smileys" checked>
Enable Smiley Addon?
</label>
</div>
</div>
<div class="label has-text-danger" id="modal-edit-error-messages">
</div>
</form>
</section>
<footer class="modal-card-foot bg-deepmatte">
<button id="modal-save-ok" onclick="save_project_settings('modal-project-edit');" class="button is-success">
OK
</button>
<button onclick="hide_modal('modal-project-edit')" class="button is-info">
CANCEL
</button>
</footer>
</div>
</div>

@ -0,0 +1,24 @@
<div class="modal" id="modal-project-not-found">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-danger">
<p class="modal-card-title has-text-white">ERROR</p>
<button onclick="hide_modal('modal-project-not-found')" class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body has-text-black">
The specified project was not found! Did you delete it? Try refreshing the page.<br>
If you believe this to be a bug, please report it
<a class="has-text-info"
href="https://github.com/domeniko-gentner/labertasche/issues"
target="_blank" rel="nofollow noopener norefferer">
here
</a>.
</section>
<footer class="modal-card-foot">
<button id="modal-ok" onclick="hide_modal('modal-project-not-found', {{ url_for('bp_dashboard.dashboard_project_list') }})"
class="button is-success">
OK
</button>
</footer>
</div>
</div>

@ -36,10 +36,11 @@
</div> </div>
<div class="card-footer"> <div class="card-footer">
<div class="card-footer-item"> <div class="card-footer-item">
<!-- TODO: onclick -->
<a class="has-text-weight-bold has-text-black is-uppercase" <a class="has-text-weight-bold has-text-black is-uppercase"
onclick="show_modal('modal-new-project');" onclick="show_modal_with_project('modal-project-edit', null)"
data-tippy-content="Create a new project" data-tippy-content="Create a new project" href="#"
href="#">NEW</a> >NEW</a>
</div> </div>
</div> </div>
</div> </div>
@ -80,22 +81,24 @@
<div class="card-footer-item has-background-danger-dark"> <div class="card-footer-item has-background-danger-dark">
<a class="has-text-weight-bold has-text-white is-uppercase" <a class="has-text-weight-bold has-text-white is-uppercase"
data-tippy-content="Delete the project and all of its content" data-tippy-content="Delete the project and all of its content"
onclick="show_modal_with_project('modal-project-delete', '{{ each['name'] }}');">DELETE</a> onclick="show_modal('modal-project-delete', '{{ each['name'] }}');">DELETE</a>
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
<a class="has-text-weight-bold has-text-black is-uppercase" <a class="has-text-weight-bold has-text-black is-uppercase"
onclick="show_modal_with_project('modal-project-edit', '{{ each['name'] }}');"
data-tippy-content="Edit the name of the project and it's properties" data-tippy-content="Edit the name of the project and it's properties"
href="#">EDIT</a> href="#">EDIT</a>
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
<a class="has-text-weight-bold has-text-black is-uppercase" <a class="has-text-weight-bold has-text-black is-uppercase"
onclick="show_modal('modal-comments-export', '{{ each['name'] }}')"
data-tippy-content="Export all comments to Hugo.<br>This is normally not needed." data-tippy-content="Export all comments to Hugo.<br>This is normally not needed."
href="#">EXPORT</a> href="#">EXPORT</a>
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
<a class="has-text-weight-bold has-text-black is-uppercase" <a class="has-text-weight-bold has-text-black is-uppercase"
data-tippy-content="Manage this project" data-tippy-content="Manage this project"
href="{{ url_for('bp_dashboard.dashboard_project_stats', project=each['name']) }}">VIEW</a> href="{{ url_for('bp_dashboard.dashboard_project_stats', project=each['name']) }}">manage</a>
</div> </div>
</div> </div>
</div> </div>
@ -103,142 +106,14 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="modal" id="modal-new-project"> {% include "modals/project_not_found.html" %}
<div class="modal-background"></div> {% include "modals/project-delete.html" %}
<div class="modal-card"> {% include "modals/project_edit.html" %}
<header class="modal-card-head"> {% include "modals/comments-export-all.html"%}
<p class="modal-card-title">New Project</p>
<button onclick="hide_modal('modal-new-project')" class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
<label for="project-name" class="has-text-black">PROJECT NAME
<input class="input is-success"
type="text"
name="project-name"
id="project-name"
placeholder="Type a name without special characters.."
>
</label>
<p id="new-project-too-short" class="is-hidden help has-text-danger">Input too short. Needs at least 1 character!</p>
<p id="new-project-invalid-name" class="is-hidden help has-text-danger">Input is invalid. Please use only a-z and 0-9.</p>
</section>
<footer class="modal-card-foot">
<button id="modal-ok" onclick="new_project_save()" class="button is-success">Save</button>
<button id="modal-cancel" onclick="hide_modal('modal-new-project')" class="button is-danger">Cancel</button>
</footer>
</div>
</div>
<div class="modal" id="modal-project-not-found">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-danger">
<p class="modal-card-title has-text-white">ERROR</p>
<button onclick="hide_modal('modal-project-not-found')" class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body has-text-black">
The specified project was not found! Did you delete it? Try refreshing the page.<br>
If you believe this to be a bug, please report it
<a class="has-text-info"
href="https://github.com/domeniko-gentner/labertasche/issues"
target="_blank" rel="nofollow noopener norefferer">
here
</a>.
</section>
<footer class="modal-card-foot">
<button id="modal-ok" onclick="hide_modal('modal-project-not-found')" class="button is-success">
OK
</button>
</footer>
</div>
</div>
<div class="modal" id="modal-project-delete">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-warning">
<p class="modal-card-title has-text-black">WARNING!</p>
<button onclick="hide_modal('modal-project-delete')" class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body has-text-black">
You are about to delete a project. All associated data will be unrecoverably lost!
Please perform a manual sql dump if you would like to retain that data.
</section>
<footer class="modal-card-foot">
<button id="modal-delete-ok" onclick="project_delete()" class="button is-danger">
OK
</button>
<button id="modal-delete-cancel" onclick="hide_modal('modal-project-delete')" class="button is-success">
CANCEL
</button>
</footer>
</div>
</div>
<div class="modal is-active" id="modal-project-edit">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head has-background-warning">
<p class="modal-card-title has-text-black">Edit Project</p>
<button onclick="hide_modal('modal-project-edit')" class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body has-text-black bg-deepmatte">
<form>
<div class="field">
<div class="control">
<input class="input" id="edit-project-name" name="edit-project-name" type="text">
</div>
<label class="label help has-text-white" for="edit-project-name">
Project Name
</label>
</div>
<div class="field">
<div class="control">
<input class="input" id="edit-project-web-url" name="edit-project-web-url" type="text">
</div>
<label class="label help has-text-white" for="edit-project-web-url">
URI of the comment system
</label>
</div>
<div class="field">
<div class="control">
<input class="input" id="edit-project-blog-url" name="edit-project-blog-url" type="text">
</div>
<label class="label help has-text-white" for="edit-project-blog-url">
URL of your Hugo site for this project
</label>
</div>
<div class="field">
<div class="control">
<input class="input" id="edit-project-output" name="edit-project-output" type="text">
</div>
<label class="label help has-text-white" for="edit-project-output">Output Directory</label>
</div>
<div class="field">
<div class="control">
<label class="checkbox help has-text-white" for="edit-project-send-otp">
<input id="edit-project-send-otp" class="checkbox" type="checkbox"
name="edit-project-send-otp" checked>
Send OTP to publish?
</label>
</div>
</div>
</form>
</section>
<footer class="modal-card-foot bg-deepmatte">
<button id="modal-delete-ok" onclick="project_delete()" class="button is-danger">
OK
</button>
<button id="modal-delete-cancel" onclick="hide_modal('modal-project-edit')" class="button is-success">
CANCEL
</button>
</footer>
</div>
</div>
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}
tippy('[data-tippy-content]', { tippy('[data-tippy-content]', {
allowHTML: true, allowHTML: true,
delay: 500 delay: 500
}); });
weburl = document.getElementById('edit-project-web-url')
weburl.value = window.location.host;
{% endblock %} {% endblock %}

Loading…
Cancel
Save