diff --git a/app.py b/app.py index 774065f..dae7978 100644 --- a/app.py +++ b/app.py @@ -4,6 +4,11 @@ from dotenv import load_dotenv from mysql.connector import errorcode from functools import wraps from werkzeug.utils import secure_filename +from pathlib import Path +import secrets +import shutil +import zipfile +import tarfile import mysql.connector import urllib import functions as func @@ -88,6 +93,7 @@ def loginRequired(f): @app.before_request def before_request(): + # app.logger.debug("[CatAsk] app.before_request triggered") global logged_in logged_in = session.get('logged_in') @@ -95,6 +101,7 @@ def before_request(): @app.context_processor def inject_stuff(): + app.logger.debug("[CatAsk] app.context_processor inject_stuff() triggered") cfg = func.loadJSON(const.configFile) # for 1.6.0 or later # questionCount = getQuestionCount() @@ -103,6 +110,7 @@ def inject_stuff(): # -- template filters -- @app.template_filter('render_markdown') def render_markdown(text): + # app.logger.debug("[CatAsk] app.template_filter render_markdown(text) triggered") return func.renderMarkdown(text) # -- client (frontend) routes -- @@ -112,14 +120,20 @@ def index(): conn = func.connectToDb() cursor = conn.cursor(dictionary=True) + app.logger.debug("[CatAsk/Home] SELECT'ing pinned questions") + cursor.execute("SELECT * FROM questions WHERE answered=%s AND pinned=%s ORDER BY creation_date DESC", (True, True)) pinned_questions = cursor.fetchall() + app.logger.debug("[CatAsk/Home] SELECT'ing non-pinned questions") + cursor.execute("SELECT * FROM questions WHERE answered=%s AND pinned=%s ORDER BY creation_date DESC", (True, False)) non_pinned_questions = cursor.fetchall() questions = pinned_questions + non_pinned_questions + app.logger.debug("[CatAsk/Home] SELECT'ing answers") + cursor.execute("SELECT * FROM answers ORDER BY creation_date DESC") answers = cursor.fetchall() @@ -135,6 +149,7 @@ def index(): cursor.close() conn.close() + return render_template('index.html', combined=combined, urllib=urllib, trimContent=func.trimContent, metadata=metadata, getRandomWord=func.getRandomWord, formatRelativeTime=func.formatRelativeTime) @app.route('/inbox/', methods=['GET']) @@ -142,6 +157,9 @@ def index(): def inbox(): conn = func.connectToDb() cursor = conn.cursor(dictionary=True) + + app.logger.debug("[CatAsk/Inbox] SELECT'ing unanswered questions") + cursor.execute("SELECT * FROM questions WHERE answered=%s ORDER BY creation_date DESC", (False,)) questions = cursor.fetchall() @@ -179,6 +197,7 @@ def login(): admin_password = request.form.get('admin_password') if admin_password == os.environ.get('ADMIN_PASSWORD'): session['logged_in'] = True + session.permanent = request.form.get('remember_me', False) return redirect(url_for('index')) else: flash("Wrong password", 'danger') @@ -189,7 +208,7 @@ def login(): else: return render_template('admin/login.html') -@admin_bp.route('/logout/') +@admin_bp.route('/logout/', methods=['POST']) @loginRequired def logout(): session['logged_in'] = False @@ -198,20 +217,56 @@ def logout(): @admin_bp.route('/', methods=['GET', 'POST']) @loginRequired def index(): + return redirect(url_for('admin.information')) + +@admin_bp.route('/information/', methods=['GET', 'POST']) +@loginRequired +def information(): + return render_template('admin/categories/instance.html') + +@admin_bp.route('/general/', methods=['GET', 'POST']) +@loginRequired +def general(): + return render_template('admin/categories/general.html') + +@admin_bp.route('/customize/', methods=['GET', 'POST']) +@loginRequired +def customize(): + return render_template('admin/categories/customize.html') + +@admin_bp.route('/emojis/', methods=['GET', 'POST']) +@loginRequired +def emojis(): + packs = func.listEmojiPacks() + if packs: + for pack in packs: + # print(pack,'\n') + json_pack = bool(pack.get('website') and pack.get('exportedAt')) + print(json_pack) + if json_pack: + break + else: + json_pack = False + emojis = func.listEmojis() + + return render_template('admin/categories/emojis.html', json_pack=json_pack, packs=packs, emojis=emojis, formatRelativeTime=func.formatRelativeTime2) + +@admin_bp.route('/blacklist/', methods=['GET', 'POST']) +@loginRequired +def blacklist(): if request.method == 'POST': action = request.form.get('action') blacklist = request.form.get('blacklist') with open(const.blacklistFile, 'w') as file: file.write(blacklist) - return {'message': 'Changes saved!'}, 200 + return {'message': 'Blacklist updated!'}, 200 else: blacklist = func.readPlainFile(const.blacklistFile) if blacklist == []: blacklist = '' - cfg_vars = func.loadJSON(const.configFile) - return render_template('admin/index.html', blacklist=blacklist, cfg=cfg_vars) + return render_template('admin/categories/blacklist.html', blacklist=blacklist) # TODO: implement first-launch setup route """ @@ -238,24 +293,27 @@ def badRequest(e): def internalServerError(e): return jsonify({'error': str(e)}), 500 +# -- question routes -- + @api_bp.route('/add_question/', methods=['POST']) def addQuestion(): from_who = request.form.get('from_who', cfg['anonName']) question = request.form.get('question', '') antispam = request.form.get('antispam', '') - # reserved for version 1.5.0 or later - # private = request.form.get('private') + cw = request.form.get('cw', '') if not question: abort(400, "Question field must not be empty") - if not antispam: - abort(400, "Anti-spam word must not be empty") if len(question) > int(cfg['charLimit']) or len(from_who) > int(cfg['charLimit']): abort(400, "Question exceeds the character limit") + if not antispam: + abort(400, "Anti-spam word must not be empty") + antispam_wordlist = func.readPlainFile(const.antiSpamFile, split=True) antispam_valid = antispam in antispam_wordlist if not antispam_valid: + # return a generic error message so bad actors wouldn't figure out the antispam list return {'error': 'An error has occurred'}, 500 blacklist = func.readPlainFile(const.blacklistFile, split=True) @@ -267,7 +325,10 @@ def addQuestion(): conn = func.connectToDb() cursor = conn.cursor() - cursor.execute("INSERT INTO questions (from_who, content, answered) VALUES (%s, %s, %s)", (from_who, question, False,)) + + app.logger.debug("[CatAsk/API/add_question] INSERT'ing new question into database") + + cursor.execute("INSERT INTO questions (from_who, content, answered, cw) VALUES (%s, %s, %s, %s)", (from_who, question, False, cw)) cursor.close() conn.close() @@ -282,6 +343,9 @@ def deleteQuestion(): conn = func.connectToDb() cursor = conn.cursor() + + app.logger.debug("[CatAsk/API/delete_question] DELETE'ing a question from database") + cursor.execute("DELETE FROM questions WHERE id=%s", (question_id,)) cursor.close() conn.close() @@ -297,17 +361,26 @@ def returnToInbox(): conn = func.connectToDb() cursor = conn.cursor() - cursor.execute("SELECT from_who, content, creation_date FROM questions WHERE id=%s", (question_id,)) + + app.logger.debug("[CatAsk/API/return_to_inbox] SELECT'ing a question from database") + + cursor.execute("SELECT from_who, content, creation_date, cw FROM questions WHERE id=%s", (question_id,)) row = cursor.fetchone() question = { 'from_who': row[0], 'content': row[1], - 'creation_date': row[2] + 'creation_date': row[2], + 'cw': row[3] } + app.logger.debug("[CatAsk/API/return_to_inbox] DELETE'ing a question from database") + cursor.execute("DELETE FROM questions WHERE id=%s", (question_id,)) - cursor.execute("INSERT INTO questions (from_who, content, creation_date, answered) VALUES (%s, %s, %s, %s)", (question["from_who"], question["content"], question["creation_date"], False,)) + + app.logger.debug("[CatAsk/API/return_to_inbox] INSERT'ing a question into database") + + cursor.execute("INSERT INTO questions (from_who, content, creation_date, answered, cw) VALUES (%s, %s, %s, %s, %s)", (question["from_who"], question["content"], question["creation_date"], False, question['cw'])) cursor.close() conn.close() @@ -322,6 +395,9 @@ def pinQuestion(): conn = func.connectToDb() cursor = conn.cursor() + + app.logger.debug("[CatAsk/API/pin_question] UPDATE'ing a question to pin it") + cursor.execute("UPDATE questions SET pinned=%s WHERE id=%s", (True, question_id)) cursor.close() conn.close() @@ -337,6 +413,9 @@ def unpinQuestion(): conn = func.connectToDb() cursor = conn.cursor() + + app.logger.debug("[CatAsk/API/unpin_question] UPDATE'ing a question to unpin it") + cursor.execute("UPDATE questions SET pinned=%s WHERE id=%s", (False, question_id)) cursor.close() conn.close() @@ -348,6 +427,7 @@ def unpinQuestion(): def addAnswer(): question_id = request.args.get('question_id', '') answer = request.form.get('answer') + cw = request.form.get('cw', '') if not question_id: abort(400, "Missing 'question_id' attribute or 'question_id' is empty") @@ -357,8 +437,14 @@ def addAnswer(): conn = func.connectToDb() try: cursor = conn.cursor() - cursor.execute("INSERT INTO answers (question_id, content) VALUES (%s, %s)", (question_id, answer)) + + app.logger.debug("[CatAsk/API/add_answer] INSERT'ing an answer into database") + + cursor.execute("INSERT INTO answers (question_id, content, cw) VALUES (%s, %s, %s)", (question_id, answer, cw)) answer_id = cursor.lastrowid + + app.logger.debug("[CatAsk/API/add_answer] UPDATE'ing question to set answered and answer_id") + cursor.execute("UPDATE questions SET answered=%s, answer_id=%s WHERE id=%s", (True, answer_id, question_id)) conn.commit() except Exception as e: @@ -370,6 +456,8 @@ def addAnswer(): return jsonify({'message': 'Answer added successfully!'}), 201 +# -- uploaders -- + @api_bp.route('/upload_favicon/', methods=['POST']) @loginRequired def uploadFavicon(): @@ -388,10 +476,157 @@ def uploadFavicon(): elif not favicon: return {'error': "favicon is not specified"}, 400 +@api_bp.route('/upload_emoji/', methods=['POST']) +@loginRequired +def uploadEmoji(): + if 'emoji' not in request.files: + return jsonify({'error': 'No file part in the request'}), 400 + + emoji = request.files['emoji'] + if emoji.filename == '': + return jsonify({'error': 'No file selected for uploading'}), 400 + + if not func.allowedFile(emoji.filename): + return jsonify({'error': 'Invalid file type. Only png, jpg, jpeg, webp, bmp, jxl supported'}), 400 + + # Secure the filename and determine the archive name + filename = secure_filename(emoji.filename) + + emoji_path = const.emojiPath / filename + + emoji.save(emoji_path) + + return jsonify({'message': f'Emoji {filename} successfully uploaded'}), 201 + +@api_bp.route('/upload_emoji_pack/', methods=['POST']) +@loginRequired +def uploadEmojiPack(): + if 'emoji_archive' not in request.files: + return jsonify({'error': 'No file part in the request'}), 400 + + emoji_archive = request.files['emoji_archive'] + if emoji_archive.filename == '': + return jsonify({'error': 'No file selected for uploading'}), 400 + + if not func.allowedArchive(emoji_archive.filename): + return jsonify({'error': 'Invalid file type. Only .zip, .tar, .tar.gz, .tar.bz2 allowed'}), 400 + + filename = secure_filename(emoji_archive.filename) + archive_name = func.stripArchExtension(filename) + + extract_path = const.emojiPath / archive_name + extract_path.mkdir(parents=True, exist_ok=True) + + archive_path = extract_path / filename + emoji_archive.save(archive_path) + + try: + if zipfile.is_zipfile(archive_path): + with zipfile.ZipFile(archive_path, 'r') as zip_ref: + zip_ref.extractall(extract_path) + elif tarfile.is_tarfile(archive_path): + with tarfile.open(archive_path, 'r:*') as tar_ref: + tar_ref.extractall(extract_path) + else: + return jsonify({'error': 'Unsupported archive format'}), 400 + + # parse meta.json if it exists + meta_json_path = extract_path / 'meta.json' + if meta_json_path.exists(): + processed_emojis = func.processEmojis(meta_json_path) + # emoji_metadata = func.loadJSON(meta_json_path) + # emojis = emoji_metadata.get('emojis', []) + + # processed_emojis = [] + # for emoji in emojis: + # emoji_info = { + # 'name': emoji['emoji']['name'], + # 'file_name': emoji['fileName'] + # } + # processed_emojis.append(emoji_info) + # app.logger.debug(f"[CatAsk/API/upload_emoji_pack] Processed emoji: {emoji_info['name']} (File: {emoji_info['file_name']})") + + # pack_json = { + # 'name': emoji_metadata['emojis'][0]['emoji']['category'].capitalize(), + # 'exportedAt': emoji_metadata["exportedAt"], + # 'emojis': processed_emojis + # } + # pack_json_name = const.emojiPath / (emoji_metadata['emojis'][0]['emoji']['category'] + '.json') + # func.saveJSON(pack_json, pack_json_name) + + return jsonify({'message': f'Successfully uploaded and processed {len(processed_emojis)} emojis from archive "{filename}".'}), 201 + + else: + return jsonify({'message': f'Archive {filename} successfully uploaded and extracted.'}), 201 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + finally: + archive_path.unlink() + +# -- getters -- + +# this isn't used anywhere yet, but maybe it will in the future + +@api_bp.route('/get_emojis/', methods=['GET']) +@loginRequired +def getEmojis(): + return func.listEmojis() + +@api_bp.route('/get_emoji_packs/', methods=['GET']) +@loginRequired +def getEmojiPacks(): + return func.listEmojiPacks() + +# -- delete routes -- + +@api_bp.route('/delete_emoji/', methods=['DELETE']) +@loginRequired +def deleteEmoji(): + emoji_name = request.args.get('emoji_name') + emoji_base_path = const.emojiPath + emoji_file_path = emoji_base_path / emoji_name + print(emoji_file_path) + emoji_ext = os.path.splitext(f"{emoji_base_path}/{emoji_file_path}") + print("emoji ext:", emoji_ext) + + if not emoji_file_path.exists() or not emoji_file_path.is_file(): + return jsonify({'error': 'Emoji not found'}), 404 + + try: + emoji_file_path.unlink() + return jsonify({'message': f'Emoji "{emoji_name}" deleted successfully'}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@api_bp.route('/delete_emoji_pack/', methods=['DELETE']) +@loginRequired +def deleteEmojiPack(): + pack_name = request.args.get('pack_name') + emojis_path = const.emojiPath + emoji_base_path = Path.cwd() / 'static' / 'emojis' / pack_name.lower() + + if not emoji_base_path.exists() or not emoji_base_path.is_dir(): + return jsonify({'error': f'Emoji pack "{pack_name.capitalize()}" not found'}), 404 + + try: + for json_file in emojis_path.glob(f'{pack_name.lower()}.json'): + json_file.unlink() + + shutil.rmtree(emoji_base_path) + return jsonify({'message': f'Emoji pack "{pack_name.capitalize()}" deleted successfully.'}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# -- misc -- + @api_bp.route('/get_question_count/', methods=['GET']) def getQuestionCount(): conn = func.connectToDb() cursor = conn.cursor() + + app.logger.debug("[CatAsk/API/get_question_count] SELECT'ing question count from database") + cursor.execute("SELECT COUNT(id) FROM questions WHERE answered=%s", (False,)) question_count = cursor.fetchone() @@ -458,7 +693,6 @@ def updateConfig(): app.config.update(cfg) return {'message': 'Changes saved!'} - app.register_blueprint(api_bp, url_prefix='/api/v1') app.register_blueprint(admin_bp, url_prefix='/admin')