mirror of
https://codeberg.org/catask-org/catask.git
synced 2025-04-19 13:23:41 -05:00
version 0.1.0
This commit is contained in:
parent
89162969b8
commit
046bcf740e
23 changed files with 806 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
.env
|
327
app.py
Normal file
327
app.py
Normal file
|
@ -0,0 +1,327 @@
|
||||||
|
from flask import Flask, Blueprint, jsonify, request, abort, render_template, flash, session, redirect, url_for
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from mysql.connector import errorcode
|
||||||
|
from functools import wraps
|
||||||
|
import mysql.connector
|
||||||
|
import functions as func
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import config as cfg
|
||||||
|
import constants as const
|
||||||
|
|
||||||
|
# used for admin routes
|
||||||
|
logged_in = False
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = Flask(cfg.appName)
|
||||||
|
app.config["SECRET_KEY"] = os.environ.get("APP_SECRET")
|
||||||
|
|
||||||
|
# -- blueprints --
|
||||||
|
api_bp = Blueprint('api', cfg.appName)
|
||||||
|
admin_bp = Blueprint('admin', cfg.appName)
|
||||||
|
|
||||||
|
# -- cli commands --
|
||||||
|
|
||||||
|
@app.cli.command("init-db")
|
||||||
|
def initDatabase():
|
||||||
|
dbName = os.environ.get("DB_NAME")
|
||||||
|
print(f"Attempting to connect to database {dbName}...")
|
||||||
|
try:
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
print("Connected successfully")
|
||||||
|
|
||||||
|
conn.database = dbName
|
||||||
|
|
||||||
|
except mysql.connector.Error as error:
|
||||||
|
if error.errno == errorcode.ER_ACCESS_DENIED_ERROR:
|
||||||
|
print("Bad credentials")
|
||||||
|
elif error.errno == errorcode.ER_BAD_DB_ERROR:
|
||||||
|
conn = mysql.connector.connect(
|
||||||
|
user=os.environ.get("DB_USER"),
|
||||||
|
password=os.environ.get("DB_PASS"),
|
||||||
|
host=os.environ.get("DB_HOST"),
|
||||||
|
database='mysql'
|
||||||
|
)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
func.createDatabase(cursor, dbName)
|
||||||
|
conn.database = dbName
|
||||||
|
else:
|
||||||
|
print("Error:", error)
|
||||||
|
return
|
||||||
|
|
||||||
|
with open('schema.sql', 'r') as schema_file:
|
||||||
|
schema = schema_file.read()
|
||||||
|
try:
|
||||||
|
cursor.execute(schema, multi=True)
|
||||||
|
print(f"Database {dbName} was successfully initialized!")
|
||||||
|
except mysql.connector.Error as error:
|
||||||
|
print(f"Failed to initialize database {dbName}: {error}")
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# -- decorators --
|
||||||
|
|
||||||
|
def loginRequired(f):
|
||||||
|
@wraps(f)
|
||||||
|
def login_required(*args, **kwargs):
|
||||||
|
if not logged_in:
|
||||||
|
return abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return login_required
|
||||||
|
|
||||||
|
# -- before request --
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
global logged_in
|
||||||
|
logged_in = session.get('logged_in')
|
||||||
|
|
||||||
|
# -- context processors --
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_stuff():
|
||||||
|
return dict(cfg=cfg, logged_in=logged_in, version=const.version, appName=cfg.appName)
|
||||||
|
|
||||||
|
# -- client (frontend) routes --
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET'])
|
||||||
|
def index():
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT * FROM questions WHERE answered=%s ORDER BY creation_date DESC", (True,))
|
||||||
|
questions = cursor.fetchall()
|
||||||
|
cursor.execute("SELECT * FROM answers ORDER BY creation_date DESC")
|
||||||
|
answers = cursor.fetchall()
|
||||||
|
|
||||||
|
combined = []
|
||||||
|
for question in questions:
|
||||||
|
question_answers = [answer for answer in answers if answer['question_id'] == question['id']]
|
||||||
|
combined.append({
|
||||||
|
'question': question,
|
||||||
|
'answers': question_answers
|
||||||
|
})
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return render_template('index.html', combined=combined, getRandomWord=func.getRandomWord, formatRelativeTime=func.formatRelativeTime, str=str)
|
||||||
|
|
||||||
|
@app.route('/inbox/', methods=['GET'])
|
||||||
|
@loginRequired
|
||||||
|
def inbox():
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT * FROM questions WHERE answered=%s", (False,))
|
||||||
|
questions = cursor.fetchall()
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return render_template('inbox.html', questions=questions, formatRelativeTime=func.formatRelativeTime, str=str, len=len)
|
||||||
|
|
||||||
|
@app.route('/q/<int:question_id>/', methods=['GET'])
|
||||||
|
def viewQuestion(question_id):
|
||||||
|
question = func.getQuestion(question_id)
|
||||||
|
answer = func.getAnswer(question_id)
|
||||||
|
return render_template('view_question.html', question=question, answer=answer, formatRelativeTime=func.formatRelativeTime, str=str)
|
||||||
|
|
||||||
|
# -- admin client routes --
|
||||||
|
|
||||||
|
@admin_bp.route('/login/', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
global logged_in
|
||||||
|
if request.method == 'POST':
|
||||||
|
admin_password = request.form.get('admin_password')
|
||||||
|
if admin_password == os.environ.get('ADMIN_PASSWORD'):
|
||||||
|
session['logged_in'] = True
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
else:
|
||||||
|
flash("Wrong password", 'danger')
|
||||||
|
return redirect(url_for('admin.login'))
|
||||||
|
else:
|
||||||
|
if logged_in:
|
||||||
|
return redirect('admin.index')
|
||||||
|
else:
|
||||||
|
return render_template('admin/login.html')
|
||||||
|
|
||||||
|
@admin_bp.route('/logout/')
|
||||||
|
@loginRequired
|
||||||
|
def logout():
|
||||||
|
session['logged_in'] = False
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
@admin_bp.route('/', methods=['GET', 'POST'])
|
||||||
|
@loginRequired
|
||||||
|
def index():
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.form.get('action')
|
||||||
|
blacklist = request.form.get('blacklist')
|
||||||
|
if action == 'update_word_blacklist':
|
||||||
|
with open(const.blacklistFile, 'w') as file:
|
||||||
|
file.write(blacklist)
|
||||||
|
flash("Changes saved!", 'success')
|
||||||
|
return redirect(url_for('admin.index'))
|
||||||
|
else:
|
||||||
|
blacklist = func.readPlainFile(const.blacklistFile)
|
||||||
|
return render_template('admin/index.html', blacklist=blacklist)
|
||||||
|
|
||||||
|
# -- server routes --
|
||||||
|
|
||||||
|
@api_bp.errorhandler(404)
|
||||||
|
def notFound():
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
|
||||||
|
@api_bp.errorhandler(400)
|
||||||
|
def badRequest(e):
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
@api_bp.errorhandler(500)
|
||||||
|
def badRequest(e):
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@api_bp.route('/add_question/', methods=['POST'])
|
||||||
|
def addQuestion():
|
||||||
|
from_who = request.form.get('from_who', 'Anonymous')
|
||||||
|
question = request.form.get('question', '')
|
||||||
|
antispam = request.form.get('antispam', '')
|
||||||
|
|
||||||
|
if not question:
|
||||||
|
abort(400, "Question field must not be empty")
|
||||||
|
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:
|
||||||
|
abort(400, "Anti-spam is not valid")
|
||||||
|
|
||||||
|
blacklist = func.readPlainFile(const.blacklistFile, split=True)
|
||||||
|
if question in blacklist:
|
||||||
|
abort(500, "An error has occurred")
|
||||||
|
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("INSERT INTO questions (from_who, content, answered) VALUES (%s, %s, %s)", (from_who, question, False,))
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {'message': 'Question asked successfully!'}, 201
|
||||||
|
|
||||||
|
@api_bp.route('/delete_question/', methods=['DELETE'])
|
||||||
|
def deleteQuestion():
|
||||||
|
question_id = request.args.get('question_id', '')
|
||||||
|
if not question_id:
|
||||||
|
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
|
||||||
|
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM questions WHERE id=%s", (question_id,))
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {'message': 'Successfully deleted question.'}, 200
|
||||||
|
|
||||||
|
@api_bp.route('/return_to_inbox/', methods=['POST'])
|
||||||
|
def returnToInbox():
|
||||||
|
question_id = request.args.get('question_id', '')
|
||||||
|
if not question_id:
|
||||||
|
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
|
||||||
|
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT from_who, content FROM questions WHERE id=%s", (question_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
question = {
|
||||||
|
'from_who': row[0],
|
||||||
|
'content': row[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.execute("DELETE FROM questions WHERE id=%s", (question_id,))
|
||||||
|
cursor.execute("INSERT INTO questions (from_who, content, answered) VALUES (%s, %s, %s)", (question["from_who"], question["content"], False,))
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {'message': 'Successfully returned question to inbox.'}, 200
|
||||||
|
|
||||||
|
@api_bp.route('/add_answer/', methods=['POST'])
|
||||||
|
def addAnswer():
|
||||||
|
question_id = request.args.get('question_id', '')
|
||||||
|
answer = request.form.get('answer')
|
||||||
|
|
||||||
|
if not question_id:
|
||||||
|
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
|
||||||
|
if not answer:
|
||||||
|
abort(400, "Missing 'answer' attribute or 'answer' is empty")
|
||||||
|
|
||||||
|
conn = func.connectToDb()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("INSERT INTO answers (question_id, content) VALUES (%s, %s)", (question_id, answer))
|
||||||
|
answer_id = cursor.lastrowid
|
||||||
|
cursor.execute("UPDATE questions SET answered=%s, answer_id=%s WHERE id=%s", (True, answer_id, question_id))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({'message': 'Answer added successfully!'}), 201
|
||||||
|
|
||||||
|
@api_bp.route('/all_questions/', methods=['GET'])
|
||||||
|
def listQuestions():
|
||||||
|
answered = request.args.get('answered', False)
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT id, from_who, creation_date, content, answered, answer_id FROM questions WHERE answered=%s", (answered,))
|
||||||
|
questions = cursor.fetchall()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(questions)
|
||||||
|
|
||||||
|
@api_bp.route('/all_answers/', methods=['GET'])
|
||||||
|
def listAnswers():
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT id, question_id, creation_date, content FROM answers")
|
||||||
|
answers = cursor.fetchall()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(answers)
|
||||||
|
|
||||||
|
@api_bp.route('/view_question/', methods=['GET'])
|
||||||
|
def viewQuestion():
|
||||||
|
question_id = request.args.get('question_id', '')
|
||||||
|
if not question_id:
|
||||||
|
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
|
||||||
|
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT id, from_who, creation_date, content, answered, answer_id FROM questions WHERE id=%s", (question_id,))
|
||||||
|
question = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(question)
|
||||||
|
|
||||||
|
@api_bp.route('/view_answer/', methods=['GET'])
|
||||||
|
def viewAnswer():
|
||||||
|
answer_id = request.args.get('answer_id', '')
|
||||||
|
if not answer_id:
|
||||||
|
abort(400, "Missing 'answer_id' attribute or 'answer_id' is empty")
|
||||||
|
|
||||||
|
conn = func.connectToDb()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT id, question_id, creation_date, content FROM answers WHERE id=%s", (answer_id,))
|
||||||
|
answer = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(answer)
|
||||||
|
|
||||||
|
app.register_blueprint(api_bp, url_prefix='/api/v1')
|
||||||
|
app.register_blueprint(admin_bp, url_prefix='/admin')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=True)
|
2
config.py
Normal file
2
config.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
appName = 'CatAsk'
|
||||||
|
fullBaseUrl = 'http://192.168.92.146:5000'
|
3
constants.py
Normal file
3
constants.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
antiSpamFile = 'wordlist.txt'
|
||||||
|
blacklistFile = 'word_blacklist.txt'
|
||||||
|
version = '0.1.0'
|
73
functions.py
Normal file
73
functions.py
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
from datetime import datetime
|
||||||
|
import humanize
|
||||||
|
import mysql.connector
|
||||||
|
import config as cfg
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import constants as const
|
||||||
|
|
||||||
|
def formatRelativeTime(date_str):
|
||||||
|
date_format = "%Y-%m-%d %H:%M:%S"
|
||||||
|
past_date = datetime.strptime(date_str, date_format)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
time_difference = now - past_date
|
||||||
|
|
||||||
|
return humanize.naturaltime(time_difference)
|
||||||
|
|
||||||
|
dbHost = os.environ.get("DB_HOST")
|
||||||
|
dbUser = os.environ.get("DB_USER")
|
||||||
|
dbPass = os.environ.get("DB_PASS")
|
||||||
|
dbName = os.environ.get("DB_NAME")
|
||||||
|
|
||||||
|
def createDatabase(cursor, dbName):
|
||||||
|
try:
|
||||||
|
cursor.execute("CREATE DATABASE {} DEFAULT CHARACTER SET 'utf8'".format(dbName))
|
||||||
|
print(f"Database {dbName} created successfully")
|
||||||
|
except mysql.connector.Error as error:
|
||||||
|
print("Failed to create database:", error)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
def connectToDb():
|
||||||
|
conn = mysql.connector.connect(
|
||||||
|
host=dbHost,
|
||||||
|
user=dbUser,
|
||||||
|
password=dbPass,
|
||||||
|
database=dbName,
|
||||||
|
pool_name=f"{cfg.appName}-pool",
|
||||||
|
pool_size=32,
|
||||||
|
autocommit=True
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def getQuestion(question_id: int):
|
||||||
|
conn = connectToDb()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT * FROM questions WHERE id=%s", (question_id,))
|
||||||
|
question = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return question
|
||||||
|
|
||||||
|
def getAnswer(question_id: int):
|
||||||
|
conn = connectToDb()
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT * FROM answers WHERE question_id=%s", (question_id,))
|
||||||
|
answer = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return answer
|
||||||
|
|
||||||
|
def readPlainFile(file, split=False):
|
||||||
|
if os.path.exists(file):
|
||||||
|
with open(file, 'r', encoding="utf-8") as file:
|
||||||
|
if split == False:
|
||||||
|
return file.read()
|
||||||
|
if split == True:
|
||||||
|
return file.read().splitlines()
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def getRandomWord():
|
||||||
|
items = readPlainFile(const.antiSpamFile, split=True)
|
||||||
|
return random.choice(items)
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
flask
|
||||||
|
python-dotenv
|
||||||
|
mysql-connector-python==8.2.0
|
7
roadmap.md
Normal file
7
roadmap.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# CatAsk 1.0 roadmap
|
||||||
|
|
||||||
|
[x] deleting answered questions OR returning them to inbox like retrospring does
|
||||||
|
[ ] bulk deleting questions from inbox
|
||||||
|
[ ] blocking askers by ip
|
||||||
|
[x] make an admin page
|
||||||
|
[x] implement an optional blacklist of words
|
21
schema.sql
Normal file
21
schema.sql
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS answers (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
question_id INT NOT NULL,
|
||||||
|
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
content TEXT NOT NULL
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
from_who VARCHAR(255) NOT NULL,
|
||||||
|
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
answered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
answer_id INT
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD CONSTRAINT fk_answer_id FOREIGN KEY (answer_id) REFERENCES answers(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE answers
|
||||||
|
ADD CONSTRAINT fk_question_id FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE;
|
5
static/css/bootstrap-icons.min.css
vendored
Normal file
5
static/css/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
6
static/css/bootstrap.min.css
vendored
Normal file
6
static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
48
static/css/style.css
Normal file
48
static/css/style.css
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
@font-face {
|
||||||
|
font-family: "Rubik";
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 100 900;
|
||||||
|
src: url("../fonts/rubik.woff2") format('woff2-variations');
|
||||||
|
}
|
||||||
|
|
||||||
|
:root, [data-bs-theme=light] {
|
||||||
|
--bs-primary: #6345d9;
|
||||||
|
--bs-primary-rgb: 214,107,26;
|
||||||
|
--bs-link-color-rgb: var(--bs-primary-rgb);
|
||||||
|
--bs-nav-link-color: var(--bs-primary);
|
||||||
|
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
|
||||||
|
--bs-font-sans-serif: "Rubik", sans-serif;
|
||||||
|
--bs-dropdown-link-hover-bg: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
--bs-btn-border-radius: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--bs-btn-bg: color-mix(in srgb, var(--bs-primary) 90%, white);
|
||||||
|
--bs-btn-border-color: var(--bs-btn-bg);
|
||||||
|
--bs-btn-hover-bg: color-mix(in srgb, var(--bs-btn-bg) 90%, black);
|
||||||
|
--bs-btn-hover-border-color: var(--bs-btn-hover-bg);
|
||||||
|
--bs-btn-active-bg: color-mix(in srgb, var(--bs-btn-bg) 80%, black);
|
||||||
|
--bs-btn-active-border-color: var(--bs-btn-active-bg);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: color-mix(in srgb, var(--bs-primary) 90%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.active, .dropdown-item:active {
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
box-shadow: 0 0 0 .25rem color-mix(in srgb, var(--bs-primary) 25%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--bs-primary), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-link.dropdown-toggle::after {
|
||||||
|
border: none;
|
||||||
|
display: none;
|
||||||
|
}
|
BIN
static/fonts/bootstrap-icons.woff2
Normal file
BIN
static/fonts/bootstrap-icons.woff2
Normal file
Binary file not shown.
BIN
static/fonts/rubik.woff2
Normal file
BIN
static/fonts/rubik.woff2
Normal file
Binary file not shown.
7
static/js/bootstrap.min.js
vendored
Normal file
7
static/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/js/htmx.min.js
vendored
Normal file
1
static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
18
templates/admin/index.html
Normal file
18
templates/admin/index.html
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Admin{% endblock %}
|
||||||
|
{% set adminLink = 'active' %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Admin panel</h1>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ url_for('admin.index') }}" method="POST">
|
||||||
|
<input type="hidden" name="action" value="update_word_blacklist">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<textarea id="blacklist" name="blacklist" style="height: 400px;" placeholder="Word blacklist" class="form-control">{{ blacklist }}</textarea>
|
||||||
|
<label for="blacklist">Word blacklist</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
15
templates/admin/login.html
Normal file
15
templates/admin/login.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ url_for('admin.login') }}" method="POST">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="password" class="form-control" id="admin_password" name="admin_password" placeholder="Password">
|
||||||
|
<label for="admin_password">Password</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
52
templates/base.html
Normal file
52
templates/base.html
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="htmx-config" content='{ "responseHandling": [{ "code": "[45]", "swap": true }] }'>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.min.css') }}">
|
||||||
|
<link rel="preload" href="{{ url_for('static', filename='fonts/bootstrap-icons.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="preload" href="{{ url_for('static', filename='fonts/rubik.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||||
|
<title>{% block title %}{% endblock %} - {{ cfg.appName }}</title>
|
||||||
|
</head>
|
||||||
|
<body class="m-2">
|
||||||
|
<div class="container-fluid">
|
||||||
|
{% if logged_in %}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
{% endif %}
|
||||||
|
<ul class="nav nav-underline mt-2 mb-2">
|
||||||
|
<li class="nav-item"><a class="nav-link {{ homeLink }}" id="home-link" href="{{ url_for('index') }}">Home</a>
|
||||||
|
{%- if logged_in -%}
|
||||||
|
<li class="nav-item"><a class="nav-link {{ inboxLink }}" id="inbox-link" href="{{ url_for('inbox') }}">Inbox</a>
|
||||||
|
<li class="nav-item"><a class="nav-link {{ adminLink }}" id="admin-link" href="{{ url_for('admin.index') }}">Admin</a></li>
|
||||||
|
{%- endif -%}
|
||||||
|
</ul>
|
||||||
|
{% if logged_in %}
|
||||||
|
<ul class="nav nav-underline mt-2 mb-2">
|
||||||
|
<li><a class="nav-link" href="{{ url_for('admin.logout') }}">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible" role="alert">
|
||||||
|
<div>{{ message }}</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>
|
||||||
|
{% block scripts %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
<footer class="py-3 my-4">
|
||||||
|
<p class="text-center text-body-secondary">CatAsk v{{ version }}</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
52
templates/inbox.html
Normal file
52
templates/inbox.html
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Inbox ({{ len(questions) }}){% endblock %}
|
||||||
|
{% set inboxLink = 'active' %}
|
||||||
|
{% block content %}
|
||||||
|
{% if questions != [] %}
|
||||||
|
{% for question in questions %}
|
||||||
|
<div class="card mb-2 mt-2 alert-placeholder" id="question-{{ question.id }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mt-0">{% if question.from_who %}{{ question.from_who }}{% else %}Anonymous{% endif %}</h5>
|
||||||
|
<h6 class="card-subtitle fw-light text-body-secondary">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
|
||||||
|
</div>
|
||||||
|
<p>{{ question.content }}</span>
|
||||||
|
<form hx-post="{{ url_for('api.addAnswer', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">
|
||||||
|
<div class="form-group d-grid gap-2">
|
||||||
|
<textarea class="form-control" required name="answer" id="answer-{{ question.id }}" placeholder="Write your answer..."></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary">Answer</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" hx-delete="{{ url_for('api.deleteQuestion', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<h2 class="text-center mt-5">Inbox is currently empty.</h2>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const appendAlert = (elementId, message, type) => {
|
||||||
|
const alertPlaceholder = document.getElementById(elementId);
|
||||||
|
const alertHtml = `
|
||||||
|
<div class="alert alert-${type} alert-dismissible" role="alert">
|
||||||
|
<div>${message}</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
alertPlaceholder.outerHTML = alertHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
const jsonResponse = event.detail.xhr.response;
|
||||||
|
if (jsonResponse) {
|
||||||
|
const parsed = JSON.parse(jsonResponse);
|
||||||
|
const msgType = event.detail.successful ? 'success' : 'error';
|
||||||
|
const targetElementId = event.detail.target.id;
|
||||||
|
appendAlert(targetElementId, parsed.message, msgType);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
87
templates/index.html
Normal file
87
templates/index.html
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Home{% endblock %}
|
||||||
|
{% set homeLink = 'active' %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="mt-3 mb-5">
|
||||||
|
<h2>Ask a question</h2>
|
||||||
|
<form hx-post="{{ url_for('api.addQuestion') }}" id="question-form" hx-target="#response-container" hx-swap="none">
|
||||||
|
<div class="form-group d-grid gap-2">
|
||||||
|
<input class="form-control" type="text" name="from_who" id="from_who" placeholder="Name (optional)">
|
||||||
|
<textarea class="form-control" required name="question" id="question" placeholder="Write your question..."></textarea>
|
||||||
|
<label for="antispam">Anti-spam: please enter the word <code>{{ getRandomWord().upper() }}</code> in lowercase</label>
|
||||||
|
<input class="form-control" type="text" required name="antispam" id="antispam">
|
||||||
|
<button type="submit" class="btn btn-primary">Ask</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="response-container" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
{% for item in combined %}
|
||||||
|
<div class="card mt-2 mb-2" id="question-{{ item.question.id }}">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mt-1 mb-1">{% if item.question.from_who %}{{ item.question.from_who }}{% else %}Anonymous{% endif %}</h5>
|
||||||
|
<h6 class="card-subtitle fw-light text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ item.question.creation_date }}" data-bs-placement="top">{{ formatRelativeTime(str(item.question.creation_date)) }}</h6>
|
||||||
|
</div>
|
||||||
|
<p class="card-text">{{ item.question.content }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<a href="{{ url_for('viewQuestion', question_id=item.question.id) }}" class="text-decoration-none text-reset">
|
||||||
|
{% for answer in item.answers %}
|
||||||
|
<p class="mb-0">{{ answer.content }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="card-footer text-body-secondary d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="text-reset btn-sm icon-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><button class="dropdown-item" onclick="copy({{ item.question.id }})">Copy link</button></li>
|
||||||
|
{% if logged_in %}
|
||||||
|
<li><button class="dropdown-item" hx-post="{{ url_for('api.returnToInbox', question_id=item.question.id) }}" hx-target="#question-{{ item.question.id }}" hx-swap="none">Return to inbox</button></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
console.log(navigator.clipboard);
|
||||||
|
function copy(questionId) {
|
||||||
|
navigator.clipboard.writeText("{{ cfg.fullBaseUrl }}/q/" + questionId + "/")
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
document.getElementById('question-form').reset();
|
||||||
|
|
||||||
|
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
|
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||||
|
|
||||||
|
const appendAlert = (elementId, message, type, onclick) => {
|
||||||
|
const alertPlaceholder = document.querySelector(`#${elementId}`);
|
||||||
|
const alertHtml = `
|
||||||
|
<div class="alert alert-${type} alert-dismissible" role="alert">
|
||||||
|
<div>${message}</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close" onclick=${onclick}></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
alertPlaceholder.outerHTML = alertHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
const jsonResponse = event.detail.xhr.response;
|
||||||
|
if (jsonResponse) {
|
||||||
|
const parsed = JSON.parse(jsonResponse);
|
||||||
|
const alertType = event.detail.successful ? 'success' : 'danger';
|
||||||
|
msgType = event.detail.successful ? parsed.message : parsed.error;
|
||||||
|
const targetElementId = event.detail.target.id;
|
||||||
|
onclick = event.detail.successful ? null : "window.location.reload()";
|
||||||
|
appendAlert(targetElementId, msgType, alertType, onclick);
|
||||||
|
document.getElementById('question-form').reset();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
26
templates/view_question.html
Normal file
26
templates/view_question.html
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card mt-2 mb-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mt-0">{% if question.from_who %}{{ question.from_who }}{% else %}Anonymous{% endif %}</h5>
|
||||||
|
<h6 class="card-subtitle fw-light text-body-secondary">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
|
||||||
|
</div>
|
||||||
|
<p class="card-text">{{ question.content }}</p>
|
||||||
|
<p class="mb-0">{{ answer.content }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-body-secondary d-flex justify-content-between align--center">
|
||||||
|
<span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="text-reset btn-sm icon-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><button class="dropdown-item" onclick="copy({{ question.id }})">Copy link</button></li>
|
||||||
|
{% if logged_in %}
|
||||||
|
<li><button class="dropdown-item" hx-post="{{ url_for('api.returnToInbox', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">Return to inbox</button></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
4
word_blacklist.txt
Normal file
4
word_blacklist.txt
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
faggot
|
||||||
|
fag
|
||||||
|
troon
|
||||||
|
tranny
|
46
wordlist.txt
Normal file
46
wordlist.txt
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
meow
|
||||||
|
entity
|
||||||
|
leaves
|
||||||
|
paper
|
||||||
|
entry
|
||||||
|
fuel
|
||||||
|
pencil
|
||||||
|
extract
|
||||||
|
combust
|
||||||
|
magpie
|
||||||
|
finch
|
||||||
|
pampus
|
||||||
|
mirror
|
||||||
|
sparkle
|
||||||
|
flint
|
||||||
|
photon
|
||||||
|
electron
|
||||||
|
neutron
|
||||||
|
tauon
|
||||||
|
quasar
|
||||||
|
candle
|
||||||
|
script
|
||||||
|
river
|
||||||
|
forest
|
||||||
|
cyan
|
||||||
|
magenta
|
||||||
|
yellow
|
||||||
|
black
|
||||||
|
marble
|
||||||
|
object
|
||||||
|
piano
|
||||||
|
violin
|
||||||
|
train
|
||||||
|
gridlock
|
||||||
|
winter
|
||||||
|
spring
|
||||||
|
summer
|
||||||
|
autumn
|
||||||
|
camera
|
||||||
|
serial
|
||||||
|
purple
|
||||||
|
fediverse
|
||||||
|
indieweb
|
||||||
|
shrimp
|
||||||
|
mention
|
||||||
|
violet
|
Loading…
Add table
Reference in a new issue