Compare commits

...

5 Commits

10 changed files with 280 additions and 35 deletions

View File

@ -7,6 +7,7 @@ from .constants import (
PermissionLevel, permission_level_string, PermissionLevel, permission_level_string,
InfoboxKind, InfoboxIcons, InfoboxHTMLClass InfoboxKind, InfoboxIcons, InfoboxHTMLClass
) )
from .lib.babycode import babycode_to_html, EMOJI
from datetime import datetime from datetime import datetime
import os import os
import time import time
@ -103,6 +104,7 @@ def create_app():
"InfoboxHTMLClass": InfoboxHTMLClass, "InfoboxHTMLClass": InfoboxHTMLClass,
"InfoboxKind": InfoboxKind, "InfoboxKind": InfoboxKind,
"__commit": commit, "__commit": commit,
"__emoji": EMOJI,
} }
@app.context_processor @app.context_processor
@ -124,4 +126,18 @@ def create_app():
def permission_string(term): def permission_string(term):
return permission_level_string(term) return permission_level_string(term)
@app.template_filter('babycode')
def babycode_filter(markup):
return babycode_to_html(markup)
@app.template_filter('extract_h2')
def extract_h2(content):
import re
pattern = r'<h2\s+id="([^"]+)"[^>]*>(.*?)<\/h2>'
matches = re.findall(pattern, content, re.IGNORECASE | re.DOTALL)
return [
{'id': id_.strip(), 'text': text.strip()}
for id_, text in matches
]
return app return app

View File

@ -5,7 +5,7 @@ import re
def tag_code(children, attr): def tag_code(children, attr):
is_inline = children.find('\n') == -1 is_inline = children.find('\n') == -1
if is_inline: if is_inline:
return f"<code class=\"inline_code\">{children}</code>" return f"<code class=\"inline-code\">{children}</code>"
else: else:
t = children.strip() t = children.strip()
button = f"<button type=button class=\"copy-code\" value={t}>Copy</button>" button = f"<button type=button class=\"copy-code\" value={t}>Copy</button>"
@ -28,6 +28,53 @@ TAGS = {
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>", "ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
} }
def make_emoji(name, code):
return f' <img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
EMOJI = {
'angry': make_emoji('angry', 'angry'),
'(': make_emoji('frown', '('),
'D': make_emoji('grin', 'D'),
'imp': make_emoji('imp', 'imp'),
'angryimp': make_emoji('impangry', 'angryimp'),
'impangry': make_emoji('impangry', 'impangry'),
'lobster': make_emoji('lobster', 'lobster'),
'|': make_emoji('neutral', '|'),
'pensive': make_emoji('pensive', 'pensive'),
')': make_emoji('smile', ')'),
'smiletear': make_emoji('smiletear', 'smiletear'),
'crytear': make_emoji('smiletear', 'crytear'),
',': make_emoji('sob', ','),
'T': make_emoji('sob', 'T'),
'cry': make_emoji('sob', 'cry'),
'sob': make_emoji('sob', 'sob'),
'o': make_emoji('surprised', 'o'),
'O': make_emoji('surprised', 'O'),
'hmm': make_emoji('think', 'hmm'),
'think': make_emoji('think', 'think'),
'thinking': make_emoji('think', 'thinking'),
'P': make_emoji('tongue', 'P'),
'p': make_emoji('tongue', 'p'),
'weary': make_emoji('weary', 'weary'),
';': make_emoji('wink', ';'),
'wink': make_emoji('wink', 'wink'),
}
TEXT_ONLY = ["code"] TEXT_ONLY = ["code"]
def break_lines(text): def break_lines(text):
@ -40,8 +87,10 @@ def babycode_to_html(s):
parser = Parser(subj) parser = Parser(subj)
parser.valid_bbcode_tags = TAGS.keys() parser.valid_bbcode_tags = TAGS.keys()
parser.bbcode_tags_only_text_children = TEXT_ONLY parser.bbcode_tags_only_text_children = TEXT_ONLY
parser.valid_emotes = EMOJI.keys()
elements = parser.parse() elements = parser.parse()
print(elements)
out = "" out = ""
def fold(element, nobr): def fold(element, nobr):
if isinstance(element, str): if isinstance(element, str):
@ -59,6 +108,8 @@ def babycode_to_html(s):
return res return res
case "link": case "link":
return f"<a href=\"{element['url']}\">{element['url']}</a>" return f"<a href=\"{element['url']}\">{element['url']}</a>"
case 'emote':
return EMOJI[element['name']]
case "rule": case "rule":
return "<hr>" return "<hr>"
for e in elements: for e in elements:

View File

@ -71,6 +71,18 @@ class Users(Model):
subscriptions.user_id = ?""" subscriptions.user_id = ?"""
return db.query(q, self.id) return db.query(q, self.id)
def can_post_to_topic(self, topic):
if self.is_guest():
return False
if self.is_mod():
return True
if topic['is_locked']:
return False
return True
class Topics(Model): class Topics(Model):
table = "topics" table = "topics"

View File

@ -1,4 +1,4 @@
from flask import Blueprint, redirect, url_for from flask import Blueprint, redirect, url_for, render_template
bp = Blueprint("app", __name__, url_prefix = "/") bp = Blueprint("app", __name__, url_prefix = "/")
@ -9,4 +9,4 @@ def index():
@bp.route("/babycode") @bp.route("/babycode")
def babycode_guide(): def babycode_guide():
return "not yet" return render_template('babycode.html')

View File

@ -18,7 +18,7 @@ def thread(slug):
POSTS_PER_PAGE = 10 POSTS_PER_PAGE = 10
thread = Threads.find({"slug": slug}) thread = Threads.find({"slug": slug})
if not thread: if not thread:
return "no" return redirect(url_for('topics.all_topics'))
post_count = Posts.count({"thread_id": thread.id}) post_count = Posts.count({"thread_id": thread.id})
page_count = max(math.ceil(post_count / POSTS_PER_PAGE), 1) page_count = max(math.ceil(post_count / POSTS_PER_PAGE), 1)
@ -69,12 +69,12 @@ def thread(slug):
def reply(slug): def reply(slug):
thread = Threads.find({"slug": slug}) thread = Threads.find({"slug": slug})
if not thread: if not thread:
return "no" return redirect(url_for('topics.all_topics'))
user = get_active_user() user = get_active_user()
if user.is_guest(): if user.is_guest():
return "no" return redirect(url_for('.thread', slug=slug))
if thread.locked() and not user.is_mod(): if thread.locked() and not user.is_mod():
return "no" return redirect(url_for('.thread', slug=slug))
post_content = request.form['post_content'] post_content = request.form['post_content']
post = create_post(thread.id, user.id, post_content) post = create_post(thread.id, user.id, post_content)
@ -102,10 +102,12 @@ def create_form():
topic = Topics.find({"id": request.form['topic_id']}) topic = Topics.find({"id": request.form['topic_id']})
user = get_active_user() user = get_active_user()
if not topic: if not topic:
return "no" flash('Invalid topic', InfoboxKind.ERROR)
return redirect(url_for('.create'))
if topic.is_locked and not get_active_user().is_mod(): if topic.is_locked and not get_active_user().is_mod():
return "no" flash(f'Topic "{topic.name}" is locked', InfoboxKind.ERROR)
return redirect(url_for('.create'))
title = request.form['title'].strip() title = request.form['title'].strip()
now = int(time.time()) now = int(time.time())
@ -128,8 +130,10 @@ def create_form():
def lock(slug): def lock(slug):
user = get_active_user() user = get_active_user()
thread = Threads.find({'slug': slug}) thread = Threads.find({'slug': slug})
if not thread:
return redirect(url_for('topics.all_topics'))
if not ((thread.user_id == user.id) or user.is_mod()): if not ((thread.user_id == user.id) or user.is_mod()):
return 'no' return redirect(url_for('.thread', slug=slug))
target_op = request.form.get('target_op') target_op = request.form.get('target_op')
thread.update({ thread.update({
'is_locked': target_op 'is_locked': target_op
@ -143,8 +147,10 @@ def lock(slug):
def sticky(slug): def sticky(slug):
user = get_active_user() user = get_active_user()
thread = Threads.find({'slug': slug}) thread = Threads.find({'slug': slug})
if not thread:
return redirect(url_for('topics.all_topics'))
if not ((thread.user_id == user.id) or user.is_mod()): if not ((thread.user_id == user.id) or user.is_mod()):
return 'no' return redirect(url_for('.thread', slug=slug))
target_op = request.form.get('target_op') target_op = request.form.get('target_op')
thread.update({ thread.update({
'is_stickied': target_op 'is_stickied': target_op
@ -167,12 +173,12 @@ def move(slug):
'id': new_topic_id 'id': new_topic_id
}) })
if not new_topic: if not new_topic:
return 'no' return redirect(url_for('topics.all_topics'))
thread = Threads.find({ thread = Threads.find({
'slug': slug 'slug': slug
}) })
if not thread: if not thread:
return 'no' return redirect(url_for('topics.all_topics'))
if new_topic.id == thread.topic_id: if new_topic.id == thread.topic_id:
flash('Thread is already in this topic.', InfoboxKind.ERROR) flash('Thread is already in this topic.', InfoboxKind.ERROR)
return redirect(url_for('.thread', slug=slug)) return redirect(url_for('.thread', slug=slug))
@ -189,7 +195,7 @@ def subscribe(slug):
user = get_active_user() user = get_active_user()
thread = Threads.find({'slug': slug}) thread = Threads.find({'slug': slug})
if not thread: if not thread:
return 'no' return redirect(url_for('topics.all_topics'))
subscription = Subscriptions.find({ subscription = Subscriptions.find({
'user_id': user.id, 'user_id': user.id,
'thread_id': thread.id, 'thread_id': thread.id,
@ -204,11 +210,11 @@ def subscribe(slug):
}) })
elif request.form['subscribe'] == 'unsubscribe': elif request.form['subscribe'] == 'unsubscribe':
if not subscription: if not subscription:
return 'no' return redirect(url_for('.thread', slug=slug))
subscription.delete() subscription.delete()
elif request.form['subscribe'] == 'read': elif request.form['subscribe'] == 'read':
if not subscription: if not subscription:
return 'no' return redirect(url_for('.thread', slug=slug))
subscription.update({ subscription.update({
'last_seen': int(time.time()) 'last_seen': int(time.time())
}) })

View File

@ -51,7 +51,7 @@ def topic(slug):
"slug": slug "slug": slug
}) })
if not target_topic: if not target_topic:
return "no" return redirect(url_for('.all_topics'))
threads_count = Threads.count({ threads_count = Threads.count({
"topic_id": target_topic.id "topic_id": target_topic.id
@ -77,7 +77,7 @@ def topic(slug):
def edit(slug): def edit(slug):
topic = Topics.find({"slug": slug}) topic = Topics.find({"slug": slug})
if not topic: if not topic:
return "no" return redirect(url_for('.all_topics'))
return render_template("topics/edit.html", topic=topic) return render_template("topics/edit.html", topic=topic)
@ -87,7 +87,7 @@ def edit(slug):
def edit_post(slug): def edit_post(slug):
topic = Topics.find({"slug": slug}) topic = Topics.find({"slug": slug})
if not topic: if not topic:
return "no" return redirect(url_for('.all_topics'))
topic.update({ topic.update({
"name": request.form.get('name', default = topic.name).strip(), "name": request.form.get('name', default = topic.name).strip(),
@ -104,7 +104,7 @@ def edit_post(slug):
def delete(slug): def delete(slug):
topic = Topics.find({"slug": slug}) topic = Topics.find({"slug": slug})
if not topic: if not topic:
return "no" return redirect(url_for('.all_topics'))
topic.delete() topic.delete()

View File

@ -9,6 +9,7 @@ from ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify from ..auth import digest, verify
from wand.image import Image from wand.image import Image
from wand.exceptions import WandException from wand.exceptions import WandException
from datetime import datetime, timedelta
import secrets import secrets
import time import time
import re import re
@ -64,7 +65,18 @@ def create_session(user_id):
return Sessions.create({ return Sessions.create({
"key": secrets.token_hex(16), "key": secrets.token_hex(16),
"user_id": user_id, "user_id": user_id,
"expires_at": int(time.time()) + 32 * 24 * 60 * 60, "expires_at": int(time.time()) + 31 * 24 * 60 * 60,
})
def extend_session(user_id):
session_obj = Sessions.find({'key': session['pyrom_session_key']})
if not session_obj:
return
new_duration = timedelta(31)
current_app.permanent_session_lifetime = new_duration
session.modified = True
session_obj.update({
'expires_at': int(time.time()) + 31 * 24 * 60 * 60
}) })
@ -269,14 +281,17 @@ def settings_form(username):
def set_avatar(username): def set_avatar(username):
user = get_active_user() user = get_active_user()
if user.is_guest(): if user.is_guest():
return 'no' flash('You must be logged in to perform this action.', InfoboxKind.ERROR)
return redirect(url_for('.settings', user.username))
if 'avatar' not in request.files: if 'avatar' not in request.files:
return 'no!...' flash('Avatar missing.', InfoboxKind.ERROR)
return redirect(url_for('.settings', user.username))
file = request.files['avatar'] file = request.files['avatar']
if file.filename == '': if file.filename == '':
return 'no..?' flash('Avatar missing.', InfoboxKind.ERROR)
return redirect(url_for('.settings', user.username))
file_bytes = file.read() file_bytes = file.read()
@ -300,7 +315,30 @@ def set_avatar(username):
old_avatar.delete() old_avatar.delete()
return redirect(url_for('.settings', username=user.username)) return redirect(url_for('.settings', username=user.username))
else: else:
return 'uhhhh no' flash('Something went wrong. Please try again later.', InfoboxKind.WARN)
return redirect(url_for('.settings', user.username))
@bp.post('/<username>/change_password')
@login_required
def change_password(username):
user = get_active_user()
password = request.form.get('new_password')
password2 = request.form.get('new_password2')
if not validate_password(password):
flash("Invalid password.", InfoboxKind.ERROR)
return redirect(url_for('.settings', username=user.username))
if password != password2:
flash("Passwords do not match.", InfoboxKind.ERROR)
return redirect(url_for('.settings', username=user.username))
hashed = digest(password)
user.update({'password_hash': hashed})
extend_session(user.id)
flash('Password updated.', InfoboxKind.INFO)
return redirect(url_for('.settings', username=user.username))
@bp.post('/<username>/clear_avatar') @bp.post('/<username>/clear_avatar')
@ -308,7 +346,7 @@ def set_avatar(username):
def clear_avatar(username): def clear_avatar(username):
user = get_active_user() user = get_active_user()
if user.is_default_avatar(): if user.is_default_avatar():
return 'no' return redirect(url_for('.settings', user.username))
old_avatar = Avatars.find({'id': user.avatar_id}) old_avatar = Avatars.find({'id': user.avatar_id})
user.update({'avatar_id': 1}) user.update({'avatar_id': 1})
@ -336,9 +374,9 @@ def log_out():
def confirm_user(user_id): def confirm_user(user_id):
target_user = Users.find({"id": user_id}) target_user = Users.find({"id": user_id})
if not target_user: if not target_user:
return "no" return redirect(url_for('.all_topics'))
if int(target_user.permission) > PermissionLevel.GUEST.value: if int(target_user.permission) > PermissionLevel.GUEST.value:
return "no" return redirect(url_for('.page', username=target_user.username))
target_user.update({ target_user.update({
"permission": PermissionLevel.USER.value, "permission": PermissionLevel.USER.value,
@ -353,9 +391,9 @@ def confirm_user(user_id):
def mod_user(user_id): def mod_user(user_id):
target_user = Users.find({"id": user_id}) target_user = Users.find({"id": user_id})
if not target_user: if not target_user:
return "no" return redirect(url_for('.all_topics'))
if target_user.is_mod(): if target_user.is_mod():
return "no" return redirect(url_for('.page', username=target_user.username))
target_user.update({ target_user.update({
"permission": PermissionLevel.MODERATOR.value, "permission": PermissionLevel.MODERATOR.value,
@ -369,9 +407,9 @@ def mod_user(user_id):
def demod_user(user_id): def demod_user(user_id):
target_user = Users.find({"id": user_id}) target_user = Users.find({"id": user_id})
if not target_user: if not target_user:
return "no" return redirect(url_for('.all_topics'))
if not target_user.is_mod(): if not target_user.is_mod():
return "no" return redirect(url_for('.page', username=target_user.username))
target_user.update({ target_user.update({
"permission": PermissionLevel.USER.value, "permission": PermissionLevel.USER.value,
@ -385,9 +423,9 @@ def demod_user(user_id):
def guest_user(user_id): def guest_user(user_id):
target_user = Users.find({"id": user_id}) target_user = Users.find({"id": user_id})
if not target_user: if not target_user:
return "no" return redirect(url_for('.all_topics'))
if target_user.is_mod(): if target_user.is_mod():
return "no" return redirect(url_for('.page', username=target_user.username))
target_user.update({ target_user.update({
"permission": PermissionLevel.GUEST.value, "permission": PermissionLevel.GUEST.value,

114
app/templates/babycode.html Normal file
View File

@ -0,0 +1,114 @@
<!-- kate: remove-trailing-space off; -->
{% extends 'base.html' %}
{% block title %}babycode guide{% endblock %}
{% block content %}
<div class=darkbg>
<h1 class="thread-title">Babycode guide</h1>
</div>
<div class="babycode-guide-container">
<div class="guide-topics">
{% set sections %}
<section class="babycode-guide-section">
<h2 id="what-is-babycode">What is babycode?</h2>
<p>You may be familiar with BBCode, a loosely related family of markup languages popular on forums. Babycode is another, simplified, dialect of those languages. It is a way of formatting text by enclosing parts of it in special tags.</p>
</section>
<section class="babycode-guide-section">
<h2 id="text-formatting-tags">Text formatting tags</h2>
<ul>
<li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br>
[b]Hello World[/b]<br>
Will become<br>
<strong>Hello World</strong>
</ul>
<ul>
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
[i]Hello World[/i]<br>
Will become<br>
<em>Hello World</em>
</ul>
<ul>
<li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br>
[s]Hello World[/s]<br>
Will become<br>
<del>Hello World</del>
</ul>
</section>
<section class="babycode-guide-section">
<h2 id="emoji">Emoji</h2>
<p>There are a few emoji in the style of old forum emotes:</p>
<table class="emoji-table">
<tr>
<th>Short code</th>
<th>Emoji result</th>
</tr>
{% for emoji in __emoji %}
<tr>
<td>:{{ emoji }}:</td>
<td>{{ __emoji[emoji] | safe }}</td>
</tr>
{% endfor %}
</table>
<p>Special thanks to the <a href="https://gh.vercte.net/forumoji/">Forumoji project</a> and its contributors for these graphics.</p>
</section>
<section class="babycode-guide-section">
<h2 id="paragraph-rules">Paragraph rules</h2>
<p>Line breaks in babycode work like Markdown: to start a new paragraph, use two line breaks:</p>
{{ '[code]paragraph 1\n\nparagraph 2[/code]' | babycode | safe }}
Will produce:<br>
{{ 'paragraph 1\n\nparagraph 2' | babycode | safe }}
<p>To break a line without starting a new paragraph, end a line with two spaces:</p>
{{ '[code]paragraph 1 \nstill paragraph 1[/code]' | babycode | safe }}
That will produce:<br>
{{ 'paragraph 1 \nstill paragraph 1' | babycode | safe }}
</section>
<section class="babycode-guide-section">
<h2 id="links">Links</h2>
<p>Loose links (starting with http:// or https://) will automatically get converted to clickable links. To add a label to a link, use<br><code class="inline-code">[url=https://example.com]Link label[/url]</code>:<br>
<a href="https://example.com">Link label</a></p>
</section>
<section class="babycode-guide-section">
<h2 id="attaching-an-image">Attaching an image</h2>
<p>To add an image to your post, use the <code class="inline-code">[img]</code> tag:<br>
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code>
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
</p>
<p>Text inside the tag becomes the alt text. The attribute is the image URL.</p>
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. The text inside the tag will become the image's alt text.</p>
</section>
<section class="babycode-guide-section">
<h2 id="adding-code-blocks">Adding code blocks</h2>
{% set code = 'func _ready() -> void:\n\tprint("hello world!")' %}
<p>There are two kinds of code blocks recognized by babycode: inline and block. Inline code blocks do not break a paragraph. They can be added with <code class="inline-code">[code]your code here[/code]</code>. As long as there are no line breaks inside the code block, it is considered inline. If there are any, it will produce this:</p>
{{ ('[code]%s[/code]' % code) | babycode | safe }}
<br>
<p>Inline code tags look like this: {{ '[code]Inline code[/code]' | babycode | safe }}</p>
<p>Babycodes are not parsed inside code blocks.</p>
</section>
<section class="babycode-guide-section">
<h2 id="quoting">Quoting</h2>
<p>Text enclosed within <code class="inline-code">[quote][/quote]</code> will look like a quote:</p>
<blockquote>A man provided with paper, pencil, and rubber, and subject to strict discipline, is in effect a universal machine.</blockquote>
</section>
<section class="babycode-guide-section">
<h2 id="lists">Lists</h2>
{% set list = '[ul]\nitem 1\n\nitem 2\n\nitem 3 \nstill item 3 (break line without inserting a new item by using two spaces at the end of a line)\n[/ul]' %}
<p>There are two kinds of lists, ordered (1, 2, 3, ...) and unordered (bullet points). Ordered lists are made with <code class="inline-code">[ol][/ol]</code> tags, and unordered with <code class="inline-code">[ul][/ul]</code>. Every new paragraph according to the <a href="#paragraph-rules">usual paragraph rules</a> will create a new list item. For example:</p>
{{ ('[code]%s[/code]' % list) | babycode | safe }}
Will produce the following list:
{{ list | babycode | safe }}
</section>
{% endset %}
{{ sections | safe }}
</div>
<div class="guide-toc">
<h2>Table of contents</h2>
{% set toc = sections | extract_h2 %}
<ul>
{% for heading in toc %}
<li><a href='#{{ heading.id }}'>{{ heading.text }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View File

@ -8,7 +8,8 @@
<label for="topic_id">Topic</label> <label for="topic_id">Topic</label>
<select name="topic_id" id="topic_id" autocomplete="off"> <select name="topic_id" id="topic_id" autocomplete="off">
{% for topic in all_topics %} {% for topic in all_topics %}
<option value="{{ topic['id'] }}" {{"selected" if (request.args.get('topic_id')) == (topic['id'] | string) else ""}}>{{ topic['name'] }}</option> {% set disable_topic = active_user and not active_user.can_post_to_topic(topic) %}
<option value="{{ topic['id'] }}" {{"selected" if (request.args.get('topic_id')) == (topic['id'] | string) else ""}} {{'disabled' if disable_topic else ''}} >{{ topic['name'] }}{{ ' (locked)' if topic.is_locked }}</option>
{% endfor %} {% endfor %}
</select><br> </select><br>
<label for="title">Thread title</label> <label for="title">Thread title</label>

View File

@ -28,5 +28,12 @@
<label for='subscribe_by_default'>Subscribe to thread by default when responding</label><br> <label for='subscribe_by_default'>Subscribe to thread by default when responding</label><br>
<input type='submit' value='Save settings'> <input type='submit' value='Save settings'>
</form> </form>
<form method='post' action='{{ url_for('users.change_password', username=active_user.username) }}'>
<label for="new_password">Change password</label><br>
<input type="password" id="new_password" name="new_password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<label for="new_password2">Confirm new password</label><br>
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input class="warn" type="submit" value="Change password">
</form>
</div> </div>
{% endblock %} {% endblock %}