1.1.0-alpha

This commit is contained in:
mst 2024-09-07 23:22:26 +03:00
parent 22a3cd4d83
commit 1ae8a2f64e
35 changed files with 506 additions and 123 deletions

View file

@ -1,6 +0,0 @@
DB_HOST = 127.0.0.1
DB_NAME = catask
DB_USER =
DB_PASS =
ADMIN_PASSWORD =
APP_SECRET =

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
venv/
__pycache__/
.env
config.py
config.json
word_blacklist.txt

4
Caddyfile Normal file
View file

@ -0,0 +1,4 @@
# example caddy configuration
catask.localhost {
reverse_proxy 127.0.0.1:5000
}

View file

@ -1,7 +1,14 @@
# CatAsk
# ![catask icon](./static/icons/catask-64.png) CatAsk
a work-in-progress minimal single-user q&a software
> [!NOTE]
> catask is alpha software, therefore bugs are expected to happen
## Prerequisites
- MySQL/MariaDB
- Python 3.10+ (3.12+ recommended)
## Install
Clone this repository: `git clone https://git.gay/mst/catask.git`
@ -26,7 +33,7 @@ If your shared hosting provider supports [WSGI](https://w.wiki/_vTN2), [FastCGI]
## Configuration
First, rename `.env.example` to `.env` and `config.example.py` to `config.py`, then configure all the values below:
First, rename `.env.example` to `.env` and `config.example.json` to `config.json`, then configure all the values below:
### .env
`DB_HOST` - database host (usually 127.0.0.1)
@ -36,10 +43,8 @@ First, rename `.env.example` to `.env` and `config.example.py` to `config.py`, t
`ADMIN_PASSWORD` - password to access admin panel
`APP_SECRET` - application secret, generate one with this command: `python -c 'import secrets; print(secrets.token_hex())'`
### config.py
`instanceTitle` - title of CatAsk instance
`instanceDesc` - description of CatAsk instance
`fullBaseUrl` - full base URL to CatAsk instance, e.g. `https://ask.example.com`
### config.json
Configure in Admin panel after installing (located at `https://yourdomain.tld/admin/`)
---
@ -51,5 +56,8 @@ If that doesn't work (e.g. tables are missing), try importing schema.sql into th
Use one of these commands to run CatAsk: `flask run` or `gunicorn -w 4 app:app` (if you have gunicorn installed) or `python app.py`
If you want CatAsk to be accessible on a specific host address, specify a `--host` option to `flask run` (e.g. `--host 0.0.0.0`)
Admin login page is located at `https://<your domain>/admin/login/`
Admin login page is located at `https://yourdomain.tld/admin/login/`
Runs on `127.0.0.1:5000` (`flask run` or `python app.py`) or `127.0.0.1:8000` (`gunicorn -w 4 app:app`), may work in a production environment
### Caddy
This repository contains an example Caddyfile that runs CatAsk on catask.localhost by reverse proxying it to 127.0.0.1:5000, you can modify it as needed

84
app.py
View file

@ -6,7 +6,6 @@ import mysql.connector
import functions as func
import os
import json
import config as cfg
import constants as const
# used for admin routes
@ -16,6 +15,9 @@ load_dotenv()
app = Flask(const.appName)
app.secret_key = os.environ.get("APP_SECRET")
cfg = func.loadJSON(const.configFile)
app.config.from_mapping(cfg)
app.config.update(cfg)
# -- blueprints --
api_bp = Blueprint('api', const.appName)
@ -83,7 +85,8 @@ def before_request():
@app.context_processor
def inject_stuff():
return dict(const=const, cfg=cfg, logged_in=logged_in, version_id=const.version_id, version=const.version, appName=const.appName)
cfg = func.loadJSON(const.configFile)
return dict(metadata=func.generateMetadata(), len=len, str=str, const=const, cfg=cfg, logged_in=logged_in, version_id=const.version_id, version=const.version, appName=const.appName)
# -- template filters --
@app.template_filter('render_markdown')
@ -101,6 +104,8 @@ def index():
cursor.execute("SELECT * FROM answers ORDER BY creation_date DESC")
answers = cursor.fetchall()
metadata = func.generateMetadata()
combined = []
for question in questions:
question_answers = [answer for answer in answers if answer['question_id'] == question['id']]
@ -111,7 +116,7 @@ def index():
cursor.close()
conn.close()
return render_template('index.html', combined=combined, getRandomWord=func.getRandomWord, formatRelativeTime=func.formatRelativeTime, str=str)
return render_template('index.html', combined=combined, metadata=metadata, getRandomWord=func.getRandomWord, formatRelativeTime=func.formatRelativeTime)
@app.route('/inbox/', methods=['GET'])
@loginRequired
@ -123,13 +128,14 @@ def inbox():
cursor.close()
conn.close()
return render_template('inbox.html', questions=questions, formatRelativeTime=func.formatRelativeTime, str=str, len=len)
return render_template('inbox.html', questions=questions, formatRelativeTime=func.formatRelativeTime)
@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, trimContent=func.trimContent)
metadata = func.generateMetadata(question, answer)
return render_template('view_question.html', question=question, answer=answer, metadata=metadata, formatRelativeTime=func.formatRelativeTime, trimContent=func.trimContent)
# -- admin client routes --
@ -162,13 +168,28 @@ 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)
return {'message': 'Changes saved!'}, 200
with open(const.blacklistFile, 'w') as file:
file.write(blacklist)
return {'message': 'Changes saved!'}, 200
else:
blacklist = func.readPlainFile(const.blacklistFile)
return render_template('admin/index.html', blacklist=blacklist)
if blacklist == []:
blacklist = ''
cfg_vars = func.loadJSON(const.configFile)
return render_template('admin/index.html', blacklist=blacklist, cfg=cfg_vars)
# TODO: implement first-launch setup route
"""
@admin_bp.route('/post-install/', methods=['GET', 'POST'])
@loginRequired
def postInstall():
if config.postInstallCompleted:
return abort(404)
else:
pass
"""
# -- server routes --
@ -181,12 +202,12 @@ def badRequest(e):
return jsonify({'error': str(e)}), 400
@api_bp.errorhandler(500)
def badRequest(e):
def internalServerError(e):
return jsonify({'error': str(e)}), 500
@api_bp.route('/add_question/', methods=['POST'])
def addQuestion():
from_who = request.form.get('from_who', 'Anonymous')
from_who = request.form.get('from_who', cfg['anonName'])
question = request.form.get('question', '')
antispam = request.form.get('antispam', '')
@ -194,15 +215,18 @@ def addQuestion():
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")
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")
return {'error': 'An error has occurred'}, 500
blacklist = func.readPlainFile(const.blacklistFile, split=True)
if question in blacklist:
abort(500, "An error has occurred")
if (question in blacklist) or (from_who in blacklist):
# return a generic error message so bad actors wouldn't figure out the blacklist
return {'error': 'An error has occurred'}, 500
conn = func.connectToDb()
cursor = conn.cursor()
@ -213,6 +237,7 @@ def addQuestion():
return {'message': 'Question asked successfully!'}, 201
@api_bp.route('/delete_question/', methods=['DELETE'])
@loginRequired
def deleteQuestion():
question_id = request.args.get('question_id', '')
if not question_id:
@ -227,6 +252,7 @@ def deleteQuestion():
return {'message': 'Successfully deleted question.'}, 200
@api_bp.route('/return_to_inbox/', methods=['POST'])
@loginRequired
def returnToInbox():
question_id = request.args.get('question_id', '')
if not question_id:
@ -251,6 +277,7 @@ def returnToInbox():
return {'message': 'Successfully returned question to inbox.'}, 200
@api_bp.route('/add_answer/', methods=['POST'])
@loginRequired
def addAnswer():
question_id = request.args.get('question_id', '')
answer = request.form.get('answer')
@ -325,6 +352,33 @@ def viewAnswer():
conn.close()
return jsonify(answer)
@api_bp.route('/update_config/', methods=['POST'])
@loginRequired
def updateConfig():
cfg = func.loadJSON(const.configFile)
configuration = request.form
for key, value in configuration.items():
cleaned_key = key.removeprefix('_') if key.startswith('_') else key
nested_keys = cleaned_key.split('.')
current_dict = cfg
for nested_key in nested_keys[:-1]:
current_dict = current_dict.setdefault(nested_key, {})
# Convert the checkbox value 'True'/'False' strings to actual booleans
if value.lower() == 'true':
value = True
elif value.lower() == 'false':
value = False
current_dict[nested_keys[-1]] = value
func.saveJSON(cfg, const.configFile)
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')

12
config.example.json Normal file
View file

@ -0,0 +1,12 @@
{
"instance": {
"title": "CatAsk",
"description": "Ask me something!",
"image": "/static/img/ca_screenshot.png",
"fullBaseUrl": "https://catask.localhost"
},
"charLimit": "512",
"anonName": "Anonymous",
"lockInbox": false,
"allowAnonQuestions": true
}

View file

@ -1,4 +0,0 @@
instanceTitle = "CatAsk"
instanceDesc = 'Ask me something!'
instanceImage = '/static/img/ca_screenshot.png'
fullBaseUrl = 'https://<yourdomain>'

View file

@ -1,4 +0,0 @@
instanceTitle = "Nekomimi's question box"
instanceDesc = 'Ask me things!'
instanceImage = '/static/img/ca_screenshot.png'
fullBaseUrl = 'http://192.168.92.146:5000'

View file

@ -1,6 +1,7 @@
antiSpamFile = 'wordlist.txt'
blacklistFile = 'word_blacklist.txt'
configFile = 'config.json'
appName = 'CatAsk'
version = '1.0.0'
version = '1.1.0'
# id (identifier) is to be interpreted as described in https://semver.org/#spec-item-9
version_id = '-alpha'

View file

@ -1,14 +1,34 @@
from flask import url_for, request
from markupsafe import Markup
from bleach.sanitizer import Cleaner
from datetime import datetime
from pathlib import Path
import mistune
import humanize
import mysql.connector
import config as cfg
import os
import random
import json
import constants as const
# load json file
def loadJSON(file_path):
# open the file
path = Path.cwd() / file_path
with open(path, 'r', encoding="utf-8") as file:
# return loaded file
return json.load(file)
# save json file
def saveJSON(dict, file_path):
# open the file
path = Path.cwd() / file_path
with open(path, 'w', encoding="utf-8") as file:
# dump the contents
json.dump(dict, file, indent=4)
cfg = loadJSON(const.configFile)
def formatRelativeTime(date_str):
date_format = "%Y-%m-%d %H:%M:%S"
past_date = datetime.strptime(date_str, date_format)
@ -110,10 +130,22 @@ def renderMarkdown(text):
clean_html = cleaner.clean(html)
return Markup(clean_html)
def generateMetadata(question=None):
def generateMetadata(question=None, answer=None):
metadata = {
'title': cfg.instanceTitle,
'description': cfg.instanceDesc,
'url': cfg.fullBaseUrl,
'image': cfg.instanceImage
'title': cfg['instance']['title'],
'description': cfg['instance']['description'],
'url': cfg['instance']['fullBaseUrl'],
'image': cfg['instance']['image']
}
# if question is specified, generate metadata for that question
if question and answer:
metadata.update({
'title': trimContent(f"{question['content']}", 150) + " | " + cfg['instance']['title'],
'description': trimContent(f"{answer['content']}", 150),
'url': cfg['instance']['fullBaseUrl'] + url_for('viewQuestion', question_id=question['id']),
'image': cfg['instance']['image']
})
# return 'metadata' dictionary
return metadata

View file

@ -4,3 +4,4 @@ mysql-connector-python==8.2.0
humanize
mistune
bleach
pathlib

View file

@ -1,7 +1,8 @@
# CatAsk 1.0.0-stable roadmap
# CatAsk stable 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
* [ ] add more customization options (theme + favicon)

File diff suppressed because one or more lines are too long

View file

@ -31,6 +31,9 @@
.btn {
--bs-btn-border-radius: .5rem;
}
.btn:not(.btn-primary,.btn-danger,.btn-success):focus-visible {
outline: revert;
}
[data-bs-theme=light] .btn-primary {
--bs-btn-bg: color-mix(in srgb, var(--bs-primary) 90%, white);
@ -86,8 +89,15 @@ a:hover {
.dropdown-menu {
padding: .5em;
}
.dropdown-item {
.dropdown-toggle::after {
border-top: .275em solid;
border-right: .275em solid transparent;
border-left: .275em solid transparent;
}
.dropdown-menu, .dropdown-item {
border-radius: var(--bs-border-radius);
}
.dropdown-item {
padding-left: .6em;
padding-right: .6em;
}
@ -104,7 +114,7 @@ a:hover {
--bs-dropdown-link-active-color: white;
}
.form-control:focus {
.form-control:focus, .form-check-input: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);
}
@ -125,3 +135,8 @@ a:hover {
.htmx-request.htmx-indicator {
display: inline-block;
}
.form-check-input:checked {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
static/icons/catask-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
static/icons/catask-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

BIN
static/icons/catask-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
static/icons/catask-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
static/icons/catask-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
static/icons/catask-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

125
static/icons/catask.svg Normal file
View file

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="380"
height="380"
viewBox="0 0 380 380"
fill="none"
version="1.1"
id="svg9"
sodipodi:docname="catask.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview9"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.48026316"
inkscape:cx="346.68493"
inkscape:cy="368.54794"
inkscape:window-width="1280"
inkscape:window-height="962"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg9" />
<rect
width="380"
height="380"
rx="70"
fill="url(#paint0_linear_603_9)"
id="rect1" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M372.86 340.836C361.458 364.034 337.594 380 310 380H69.9999C42.4057 380 18.5407 364.033 7.13956 340.835C0.0540733 303.832 2.43122 265.23 21.5492 228.243C22.3857 226.625 24.2343 225.797 25.9907 226.281C61.5026 236.067 97.5669 264.887 131.247 340.717C131.983 342.374 133.752 343.367 135.546 343.105C160.424 339.463 177.96 339.001 190 339.001C202.04 339.001 219.576 339.463 244.454 343.105C246.248 343.367 248.017 342.375 248.753 340.717C282.433 264.887 318.497 236.067 354.009 226.281C355.765 225.797 357.614 226.625 358.45 228.243C377.568 265.23 379.945 303.833 372.86 340.836Z"
fill="#DA75FF"
id="path1"
style="fill:#e59bff;fill-opacity:1" />
<mask
id="path-3-inside-1_603_9"
fill="white">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M158 221C158 234.256 168.744 245 182 245C195.256 245 206 234.256 206 221V210.336C212.376 209.056 219.496 207.168 226.696 204.36C259.784 191.504 278 166.16 278 133C278 92.856 254 45 190 45C146.64 45 110 77.976 110 117C110 130.256 120.744 141 134 141C147.256 141 158 130.256 158 117C158 109 170.472 93 190 93C214 93 230 109 230 125C230 157 186.824 164.952 182 165C168.744 165 158 175.744 158 189V221ZM206 285C206 298.255 195.255 309 182 309C168.745 309 158 298.255 158 285C158 271.745 168.745 261 182 261C195.255 261 206 271.745 206 285Z"
id="path2" />
</mask>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 154,218 c 0,13.256 10.744,24 24,24 13.256,0 24,-10.744 24,-24 v -10.664 c 6.376,-1.28 13.496,-3.168 20.696,-5.976 C 255.784,188.504 274,163.16 274,130 274,89.856 250,42 186,42 c -43.36,0 -80,32.976 -80,72 0,13.256 10.744,24 24,24 13.256,0 24,-10.744 24,-24 0,-8 12.472,-24 32,-24 24,0 40,16 40,32 0,32 -43.176,39.952 -48,40 -13.256,0 -24,10.744 -24,24 z m 48,64 c 0,13.255 -10.745,24 -24,24 -13.255,0 -24,-10.745 -24,-24 0,-13.255 10.745,-24 24,-24 13.255,0 24,10.745 24,24 z"
fill="url(#paint1_linear_603_9)"
id="path3"
style="fill:url(#paint1_linear_603_9)" />
<path
d="M206 210.336L205.606 208.375L204 208.698V210.336H206ZM226.696 204.36L225.972 202.496L225.969 202.497L226.696 204.36ZM182 165V167H182.01L182.02 167L182 165ZM182 243C169.849 243 160 233.151 160 221H156C156 235.361 167.639 247 182 247V243ZM204 221C204 233.151 194.151 243 182 243V247C196.361 247 208 235.361 208 221H204ZM204 210.336V221H208V210.336H204ZM225.969 202.497C218.9 205.254 211.895 207.113 205.606 208.375L206.394 212.297C212.857 210.999 220.092 209.082 227.423 206.223L225.969 202.497ZM276 133C276 165.261 258.362 189.911 225.972 202.496L227.42 206.224C261.206 193.097 280 167.059 280 133H276ZM190 47C221.445 47 242.863 58.7285 256.457 75.2182C270.103 91.7706 276 113.29 276 133H280C280 112.566 273.897 90.0854 259.543 72.6738C245.137 55.1995 222.555 43 190 43V47ZM112 117C112 79.2736 147.541 47 190 47V43C145.739 43 108 76.6784 108 117H112ZM134 139C121.849 139 112 129.151 112 117H108C108 131.361 119.639 143 134 143V139ZM156 117C156 129.151 146.151 139 134 139V143C148.361 143 160 131.361 160 117H156ZM190 91C179.667 91 171.206 95.2364 165.34 100.513C162.408 103.15 160.098 106.069 158.51 108.896C156.941 111.69 156 114.534 156 117H160C160 115.466 160.618 113.31 161.998 110.854C163.359 108.431 165.387 105.85 168.014 103.487C173.266 98.7636 180.805 95 190 95V91ZM232 125C232 107.617 214.804 91 190 91V95C213.196 95 228 110.383 228 125H232ZM182.02 167C183.477 166.985 187.271 166.429 192.047 165.163C196.878 163.883 202.876 161.836 208.74 158.752C220.422 152.608 232 142.085 232 125H228C228 139.915 217.99 149.368 206.878 155.212C201.345 158.122 195.645 160.072 191.023 161.296C186.347 162.535 182.935 162.991 181.98 163L182.02 167ZM160 189C160 176.849 169.849 167 182 167V163C167.639 163 156 174.639 156 189H160ZM160 221V189H156V221H160ZM182 311C196.359 311 208 299.359 208 285H204C204 297.15 194.15 307 182 307V311ZM156 285C156 299.359 167.641 311 182 311V307C169.85 307 160 297.15 160 285H156ZM182 259C167.641 259 156 270.641 156 285H160C160 272.85 169.85 263 182 263V259ZM208 285C208 270.641 196.359 259 182 259V263C194.15 263 204 272.85 204 285H208Z"
fill="url(#paint2_linear_603_9)"
fill-opacity="0.58"
mask="url(#path-3-inside-1_603_9)"
id="path4"
style="fill:none" />
<defs
id="defs9">
<linearGradient
id="paint0_linear_603_9"
x1="190"
y1="0"
x2="190"
y2="380"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#CE95FF"
id="stop4" />
<stop
offset="1"
stop-color="#8B52BC"
id="stop5"
style="stop-color:#6a3a93;stop-opacity:1;" />
</linearGradient>
<linearGradient
id="paint1_linear_603_9"
x1="182"
y1="45"
x2="182"
y2="309"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-4,-3)">
<stop
stop-color="#CE5AFF"
id="stop6"
offset="0"
style="stop-color:#ffffff;stop-opacity:1;" />
<stop
offset="1"
stop-color="#C12FFF"
id="stop7"
style="stop-color:#d973ff;stop-opacity:1;" />
</linearGradient>
<linearGradient
id="paint2_linear_603_9"
x1="194"
y1="45"
x2="194"
y2="309"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#B34999"
id="stop8"
offset="0"
style="stop-color:#ffffff;stop-opacity:1;" />
<stop
offset="1"
stop-color="#B14CD5"
id="stop9"
style="stop-color:#e7c5f0;stop-opacity:1;" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,20 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1646 10224 c-309 -42 -583 -147 -834 -320 -355 -246 -619 -611 -737
-1022 -28 -99 -26 -89 -52 -233 -17 -100 -18 -248 -18 -3539 l0 -3435 22 -110
c29 -147 28 -141 63 -253 168 -529 565 -961 1077 -1170 161 -66 337 -113 488
-130 33 -4 1599 -7 3480 -7 3675 0 3462 -3 3676 51 574 143 1044 544 1277
1087 45 104 93 257 112 353 39 199 38 136 38 3619 0 1986 -3 3393 -9 3440 -75
656 -464 1209 -1049 1493 -180 88 -316 131 -522 167 l-133 23 -3390 0 c-2787
0 -3408 -2 -3489 -14z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1,001 B

View file

@ -4,28 +4,94 @@
{% block content %}
<h1 class="mb-3">Admin panel</h1>
<div id="response-container"></div>
<form hx-post="{{ url_for('admin.index') }}" hx-target="#response-container" hx-swap="none">
<input type="hidden" name="action" value="update_word_blacklist">
<div class="form-group mb-3">
<label for="blacklist"><h2>Word blacklist</h2></label>
<textarea id="blacklist" name="blacklist" style="height: 400px; resize: vertical;" placeholder="Word blacklist" class="form-control">{{ blacklist }}</textarea>
</div>
<button type="submit" class="btn btn-primary">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Save
</button>
</form>
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none">
<h2>Instance</h2>
<div class="form-group mb-3">
<label for="instance.title">Title <small class="text-secondary">(e.g. My question box)</small></label>
<input type="text" id="instance.title" name="instance.title" value="{{ cfg.instance.title }}" class="form-control">
</div>
<div class="form-group mb-3">
<label for="instance.description">Description <small class="text-secondary">(e.g. Ask me a question!)</small></label>
<input type="text" id="instance.description" name="instance.description" value="{{ cfg.instance.description }}" class="form-control">
</div>
<div class="form-group mb-3">
<label for="instance.image">Relative image path <small class="text-secondary">(default: /static/img/ca_screenshot.png)</small></label>
<input type="text" id="instance.image" name="instance.image" value="{{ cfg.instance.image }}" class="form-control">
</div>
<div class="form-group mb-3">
<label for="instance.fullBaseUrl">Base URL <small class="text-secondary">(e.g. https://example.com)</small></label>
<input type="text" id="instance.fullBaseUrl" name="instance.fullBaseUrl" value="{{ cfg.instance.fullBaseUrl }}" class="form-control">
</div>
<h2>General</h2>
<div class="form-group mb-3">
<label for="charLimit">Question character limit</label>
<input type="number" id="charLimit" name="charLimit" value="{{ cfg.charLimit }}" class="form-control">
</div>
<div class="form-group mb-3">
<label for="anonName">Name for anonymous users</label>
<input type="text" id="anonName" name="anonName" value="{{ cfg.anonName }}" class="form-control">
</div>
<div class="form-check mb-2">
<input
class="form-check-input"
type="checkbox"
name="_lockInbox"
id="_lockInbox"
value="{{ cfg.lockInbox }}"
{% if cfg.lockInbox == true %}checked{% endif %}>
<input type="hidden" id="lockInbox" name="lockInbox" value="{{ cfg.lockInbox }}">
<label for="_lockInbox" class="form-check-label">Lock inbox and don't allow new questions</label>
</div>
<div class="form-check mb-3">
<input
class="form-check-input"
type="checkbox"
name="_allowAnonQuestions"
id="_allowAnonQuestions"
value="{{ cfg.allowAnonQuestions }}"
{% if cfg.allowAnonQuestions == true %}checked{% endif %}>
<input type="hidden" id="allowAnonQuestions" name="allowAnonQuestions" value="{{ cfg.allowAnonQuestions }}">
<label for="_allowAnonQuestions" class="form-check-label">Allow anonymous questions</label>
</div>
<div class="form-group mb-3">
<button type="submit" class="btn btn-primary mt-3">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Save
</button>
</div>
</form>
<hr class="mt-4 mb-4">
<form hx-post="{{ url_for('admin.index') }}" hx-target="#response-container" hx-swap="none">
<input type="hidden" name="action" value="update_word_blacklist">
<div class="form-group mb-3">
<label for="blacklist"><h2>Word blacklist</h2></label>
<p class="text-secondary">Blacklisted words for questions; one word per line</p>
<textarea id="blacklist" name="blacklist" style="height: 300px; resize: vertical;" class="form-control">{{ blacklist }}</textarea>
<button type="submit" class="btn btn-primary mt-3">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Save
</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
</script>
<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>${message}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;

View file

@ -1,15 +1,20 @@
{% extends 'base.html' %}
{% block title %}Admin Login{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-4 m-auto mt-5">
<div class="card">
<div class="card-body">
<h2 class="text-center mb-4 mt-2">Login to {{ cfg.instance.title }}</h2>
<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">
<input type="password" required 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>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -3,26 +3,54 @@
<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>
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='icons/favicon/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='icons/favicon/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='icons/favicon/favicon-16x16.png') }}">
<link rel="mask-icon" href="{{ url_for('static', filename='icons/favicon/safari-pinned-tab.svg') }}" color="#5bbad5">
<link rel="shortcut icon" href="{{ url_for('static', filename='icons/favicon/favicon.ico') }}">
<!-- metadata -->
<!-- Primary Meta Tags -->
<meta name="title" content="{{ metadata.title }}" />
<meta name="description" content="{{ metadata.description }}" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ metadata.url }}" />
<meta property="og:title" content="{{ metadata.title }}" />
<meta property="og:description" content="{{ metadata.description }}" />
<meta property="og:image" content="{{ metadata.image }}" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="{{ metadata.url }}" />
<meta property="twitter:title" content="{{ metadata.title }}" />
<meta property="twitter:description" content="{{ metadata.description }}" />
<meta property="twitter:image" content="{{ metadata.image }}" />
<script src="{{ url_for('static', filename='js/color-modes.js') }}"></script>
<title>{% block title %}{% endblock %} - {{ const.appName }}</title>
<title>{% block title %}{% endblock %} | {{ const.appName }}</title>
</head>
<body class="m-2">
<body class="ms-2 me-2 mb-2">
<a class="visually-hidden-focusable" href="#main-content">Skip to content</a>
<div class="container-fluid">
{% if logged_in %}
<div class="d-flex justify-content-between align-items-center mt-3 {% if logged_in %}mb-3{% endif %}">
<ul class="nav nav-underline {% if not logged_in %}mb-3{% endif %}">
<ul class="nav nav-underline position-relative {% if not logged_in %}mb-3{% endif %}">
<li class="nav-item d-flex align-items-center"><a href="{{ url_for('index') }}"><img src="{{ url_for('static', filename='icons/catask.svg') }}" width="32" height="32"></a></li>
<li class="nav-item"><a class="nav-link {{ homeLink }}" id="home-link" href="{{ url_for('index') }}">Home</a>
<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>
</ul>
<ul class="nav nav-underline m-0">
<li><a class="nav-link p-0" href="{{ url_for('admin.logout') }}">Logout</a></li>
<li><a class="nav-link" href="{{ url_for('admin.logout') }}">Logout</a></li>
</ul>
</div>
{% else %}
@ -38,7 +66,9 @@
{% endfor %}
{% endif %}
{% endwith %}
<div id="main-content">
{% block content %}{% endblock %}
</div>
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/eruda" onload="eruda.init()"></script> -->
@ -79,7 +109,7 @@
</div>
</div>
<div class="d-inline-block">
<a href="https://git.gay/mst/catask" target="_blank" class="text-body-secondary">{{ const.appName }} v{{ version }}{{ version_id }}</a>
<a href="https://git.gay/mst/catask" target="_blank" class="text-body-secondary text-decoration-none">{{ const.appName }} v{{ version }}{{ version_id }}</a>
</div>
</footer>
</div>

View file

@ -1,5 +1,5 @@
{% extends 'base.html' %}
{% block title %}Inbox ({{ len(questions) }}){% endblock %}
{% block title %}Inbox {% if len(questions) > 0 %}({{ len(questions) }}){% endif %}{% endblock %}
{% set inboxLink = 'active' %}
{% block content %}
{% if questions != [] %}
@ -14,14 +14,16 @@
</div>
<div class="card-body">
<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">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Answer
</button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">Delete</button>
<div class="form-group d-sm-grid d-md-block gap-2">
<textarea class="form-control mb-2" required name="answer" id="answer-{{ question.id }}" placeholder="Write your answer..."></textarea>
<div class="d-flex flex-sm-column flex-md-row-reverse gap-2">
<button type="submit" class="btn btn-primary">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Answer
</button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">Delete</button>
</div>
</div>
</form>
</div>
@ -37,7 +39,7 @@
<p>Are you sure you want to delete this question?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" hx-delete="{{ url_for('api.deleteQuestion', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">Confirm</button>
</div>
</div>

View file

@ -3,24 +3,25 @@
{% set homeLink = 'active' %}
{% block content %}
<div class="mt-5 mb-sm-2 mb-md-5">
<h1 class="text-center fw-bold">{{ cfg.instanceTitle }}</h1>
<h5 class="text-center fw-light">{{ cfg.instanceDesc }}</h5>
<h1 class="text-center fw-bold">{{ cfg.instance.title }}</h1>
<h5 class="text-center fw-light">{{ cfg.instance.description }}</h5>
</div>
<div class="row">
<div class="col-sm-4">
<div class="mt-3 mb-5 sticky-md-top">
<div class="col-sm-{% if combined %}4{% else %}8{% endif %}{% if not combined %} m-auto{% endif %}">
<div class="mb-5 sticky-md-top">
{% if cfg.lockInbox == false %}
<br>
<h2>Ask a question</h2>
<form class="d-lg-block" hx-post="{{ url_for('api.addQuestion') }}" id="question-form" hx-target="#response-container" hx-swap="none">
<div class="form-group mb-2">
<input class="form-control" type="text" name="from_who" id="from_who" placeholder="Name (optional)">
<input {% if cfg.allowAnonQuestions == false %}required{% endif %} class="form-control" type="text" name="from_who" id="from_who" placeholder="Name {% if cfg.allowAnonQuestions == true %}(optional){% endif %}">
</div>
<div class="form-group mb-2">
<textarea class="form-control" required name="question" id="question" placeholder="Write your question..."></textarea>
</div>
<div class="form-group mb-2">
<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">
<label for="antispam">Anti-spam: please enter the word <code class="text-uppercase">{{ getRandomWord() }}</code> in lowercase</label>
<input class="form-control" type="text" required name="antispam" id="antispam" autocomplete="off">
</div>
<div class="form-group d-grid d-lg-flex justify-content-lg-end mt-3">
<button type="submit" class="btn btn-primary">
@ -31,14 +32,25 @@
</div>
</form>
<div id="response-container" class="mt-3"></div>
{% else %}
<br>
<h2 class="text-center">New questions cannot be asked right now.</h2>
{% endif %}
</div>
</div>
{% if combined %}
<div class="col-sm-8">
{% for item in combined %}
<div class="card mt-3 mb-3" 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>
<h5 class="card-title mt-1 mb-1">
{% if item.question.from_who %}
{{ item.question.from_who }}
{% else %}
<i class="bi bi-incognito" data-bs-toggle="tooltip" data-bs-title="This question was asked anonymously" data-bs-placement="top"></i> {{ cfg.anonName }}
{% 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>
<div class="card-text markdown-content">{{ item.question.content | render_markdown }}</div>
@ -49,28 +61,29 @@
<div class="markdown-content">{{ answer.content }}</div>
</div>
</a>
<div class="card-footer text-body-secondary d-flex justify-content-between align-items-center">
<div class="card-footer pt-0 pb-0 ps-3 pe-2 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 no-arrow 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 bg-hover-danger text-danger" 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 class="dropdown">
<button class="btn btn-sm pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ item.question.id }})">Copy link</button></li>
{% if logged_in %}
<li><button class="bg-hover-danger text-danger 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 %}
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function copy(questionId) {
navigator.clipboard.writeText("{{ cfg.fullBaseUrl }}/q/" + questionId + "/")
navigator.clipboard.writeText("{{ cfg.instance.fullBaseUrl }}/q/" + questionId + "/")
}
</script>
<script>
@ -89,7 +102,7 @@
</div>
`;
alertPlaceholder.outerHTML = alertHtml;
alertPlaceholder.innerHTML = alertHtml;
}
document.addEventListener('htmx:afterRequest', function(event) {

View file

@ -1,34 +1,41 @@
{% extends 'base.html' %}
{% block title %}"{{ trimContent(question.content, 15) }}" - "{{ trimContent(answer.content, 15) }}"{% endblock %}
{% block content %}
<div class="container-md">
<div class="card mt-2 mb-2" id="question-{{ 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 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>
<div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
</div>
<div class="card-body">
<p class="mb-0">{{ answer.content }}</p>
</div>
<div class="card-footer pt-0 pb-0 ps-3 pe-2 text-body-secondary d-flex justify-content-between align-items-center">
<span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>
<div class="dropdown">
<button class="btn btn-sm pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ question.id }})">Copy link</button></li>
{% if logged_in %}
<li><button class="bg-hover-danger text-danger 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 class="row">
<div class="col-sm-6 m-auto">
<div class="card mt-2 mb-2" id="question-{{ 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 question.from_who %}{{ question.from_who }}{% else %}<i class="bi bi-incognito"></i> {{ cfg.anonName }}{% endif %}</h5>
<h6 class="card-subtitle fw-light text-body-secondary">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
</div>
<div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
</div>
<div class="card-body">
<p class="mb-0">{{ answer.content }}</p>
</div>
<div class="card-footer pt-0 pb-0 ps-3 pe-2 text-body-secondary d-flex justify-content-between align-items-center">
<span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>
<div class="dropdown">
<button class="btn btn-sm pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ question.id }})">Copy link</button></li>
{% if logged_in %}
<li><button class="bg-hover-danger text-danger 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>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function copy(questionId) {
navigator.clipboard.writeText("{{ cfg.instance.fullBaseUrl }}/q/" + questionId + "/")
}
</script>
<script>
const appendAlert = (elementId, message, type) => {
const alertPlaceholder = document.getElementById(elementId);