Compare commits

..

9 Commits

11 changed files with 270 additions and 28 deletions

View File

@@ -149,6 +149,13 @@ def clear_stale_sessions():
for sess in stale_sessions:
sess.delete()
def clear_api_limits():
from .db import db
from .models import APIRateLimits
with db.transaction():
limits = APIRateLimits.select()
for l in limits:
l.delete()
cache = Cache()
@@ -203,12 +210,14 @@ def create_app():
from app.routes.guides import bp as guides_bp
from app.routes.mod import bp as mod_bp
from app.routes.posts import bp as posts_bp
from app.routes.api import bp as api_bp
app.register_blueprint(topics_bp)
app.register_blueprint(threads_bp)
app.register_blueprint(users_bp)
app.register_blueprint(guides_bp)
app.register_blueprint(mod_bp)
app.register_blueprint(posts_bp)
app.register_blueprint(api_bp)
with app.app_context():
from .schema import create as create_tables
@@ -221,6 +230,7 @@ def create_app():
create_deleted_user()
clear_stale_sessions()
clear_api_limits()
reparse_babycode()
@@ -318,6 +328,15 @@ def create_app():
else:
return render_template('common/404.html'), e.code
@app.errorhandler(405)
def _handle_405(e):
if request.path.startswith('/hyperapi/'):
return '<h1>method not allowed</h1>', e.code
elif request.path.startswith('/api/'):
return {'error': 'method not allowed'}, e.code
else:
return render_template('common/404.html'), e.code
@app.errorhandler(403)
def _handle_403(e):
if request.path.startswith('/hyperapi/'):

View File

@@ -105,6 +105,14 @@ def login_required(view_func):
return view_func(*args, **kwargs)
return wrapper
def hard_login_required(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
if not is_logged_in():
abort(403)
return view_func(*args, **kwargs)
return wrapper
def mod_only(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):

21
app/routes/api.py Normal file
View File

@@ -0,0 +1,21 @@
from flask import Blueprint, request
from ..auth import is_logged_in, hard_login_required, get_active_user
from ..lib.babycode import babycode_to_html
from ..models import APIRateLimits
bp = Blueprint('api', __name__, url_prefix='/api/')
@bp.post('/babycode-preview/')
@hard_login_required
def babycode_preview():
user = get_active_user()
if not APIRateLimits.is_allowed(user.id, 'babycode_preview', 5):
return {'error': 'too many requests'}, 429
markup = str(request.json.get('markup', ''))
if not markup:
return {'error': 'markup field missing or invalid type'}, 400
banned_tags = request.json.get('banned_tags', [])
if not isinstance(banned_tags, list):
return {'error': 'banned_tags field is invalid type'}, 400
rendered = babycode_to_html(markup, banned_tags).result
return {'html': rendered}

View File

@@ -62,7 +62,7 @@ def thread(thread_id, slug):
user = get_active_user()
if user:
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
if subscription:
if subscription and last_post['created_at'] > int(subscription.last_seen):
subscription.update({'last_seen': last_post['created_at']})
return render_template(
'threads/thread.html', thread=thread,
@@ -82,13 +82,16 @@ def reply(thread_id):
if not user.can_post_to_thread_or_topic(thread):
return redirect(url_for('.thread_by_id', thread_id=thread_id))
post = Posts.new(user.id, thread.id, request.form.get('babycode_content'))
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
if get_form_checkbox('subscribe'):
if not Subscriptions.find({'user_id': user.id, 'thread_id': thread.id}):
Subscriptions.create({
if not subscription:
subscription = Subscriptions.create({
'user_id': user.id,
'thread_id': thread.id,
'last_seen': time_now(),
})
if subscription and subscription.last_seen < time_now():
subscription.update({'last_seen': time_now()})
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=post.id, _anchor=f'post-{post.id}'))
@bp.get('/<int:thread_id>/edit/')

View File

@@ -13,6 +13,8 @@
{%- endif -%}
</head>
<body>
<bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8>
<bitty-8 data-connect="/static/js/bits/ui.js"></bitty-8>
{%- include 'common/topnav.html' -%}
{%- with messages = get_flashed_messages(with_categories=true) -%}
{%- if messages -%}
@@ -23,6 +25,7 @@
{%- endwith -%}
{%- block content -%}{%- endblock -%}
{%- include 'common/footer.html' -%}
<script type="module" src="/static/js/vnd/bitty-8.0.0.js"></script>
<script src="{{'/static/js/ui.js' | cachebust}}"></script>
</body>
</html>

View File

@@ -72,15 +72,15 @@
</span>
{%- endmacro %}
{% macro tabs(prefix='', labels = []) -%}
<div class="tab-container">
{% macro tabs(prefix='', labels=[], signal_ss=[], signal_rs=[]) -%}
<div class="tab-container" data-r="setTab">
<div class="tab-bar" role="tablist">
{%- for tab_label in labels -%}
<button type="button" class="tab-button" role="tab" aria-selected="{{'true' if loop.index0==0 else 'false'}}" id="{{prefix+'-'+(tab_label | lower)+'-tab'}}" aria-controls="{{prefix+'-'+(tab_label | lower)+'-content'}}" disabled>{{tab_label}}</button>
<button type="button" class="tab-button" role="tab" aria-selected="{{'true' if loop.index0==0 else 'false'}}" id="{{prefix+'-'+(tab_label | lower)+'-tab'}}" aria-controls="{{prefix+'-'+(tab_label | lower)+'-content'}}" disabled data-r="enhance" data-s="setTab {{signal_ss[loop.index0] if signal_ss[loop.index0] else ''}}" data-tab-index="{{loop.index0}}">{{tab_label}}</button>
{%- endfor -%}
</div>
{%- for tab_label in labels -%}
<div class="plank secondary-bg even no-shadow tab-content {{'hidden' if loop.index0!=0 else ''}}" role="tabpanel" aria-labelledby="{{prefix+'-'+(tab_label | lower)+'-tab'}}" id="{{prefix+'-'+(tab_label | lower)+'-content'}}">
<div class="plank secondary-bg even no-shadow tab-content {{'hidden' if loop.index0!=0 else ''}}" role="tabpanel" aria-labelledby="{{prefix+'-'+(tab_label | lower)+'-tab'}}" id="{{prefix+'-'+(tab_label | lower)+'-content'}}" data-r="{{signal_rs[loop.index0] if signal_rs[loop.index0] else ''}}">
{{- caller(loop.index0) -}}
</div>
{%- endfor -%}
@@ -94,24 +94,25 @@
id='babycode-content',
banned_tags=[]
) -%}
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%}
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview'], signal_ss=[none, 'babycodePreviewInit'], signal_rs=[none, 'babycodePreview']) -%}
{%- if idx == 0 -%}
<span class="babycode-editor-controls">
<span class="button-row">
<button type="button" class="minimal" disabled><b>B</b></button>
<button type="button" class="minimal" disabled><i>i</i></button>
<button type="button" class="minimal" disabled><s>S</s></button>
<button type="button" class="minimal" disabled><u>U</u></button>
<button type="button" class="minimal" disabled><code>://</code></button>
<button type="button" class="minimal" disabled><code>&lt;/&gt;</code></button>
<button type="button" class="minimal" disabled>1.</button>
<button type="button" class="minimal" disabled>&bullet;</button>
<button type="button" class="minimal" disabled><img src="/static/emoji/angry.png" class="emoji"></button>
<span class="button-row js-only" data-r="enhance">
<button type="button" title="insert bold" class="minimal" data-babycode-tag="b" data-s="insertBabycode"><b>B</b></button>
<button type="button" title="insert italic" class="minimal" data-babycode-tag="i" data-s="insertBabycode"><i>i</i></button>
<button type="button" title="insert strikethrough" class="minimal" data-babycode-tag="s" data-s="insertBabycode"><s>S</s></button>
<button type="button" title="insert underline" class="minimal" data-babycode-tag="u" data-s="insertBabycode"><u>U</u></button>
<button type="button" title="insert link" class="minimal" data-babycode-tag="url" data-prefill="link label" data-s="insertBabycode"><code>://</code></button>
<button type="button" title="insert code block" class="minimal" data-babycode-tag="code" data-break-line data-s="insertBabycode"><code>&lt;/&gt;</code></button>
<button type="button" title="insert ordered list" class="minimal" data-babycode-tag="ol" data-break-line data-s="insertBabycode">1.</button>
<button type="button" title="insert unordered list" class="minimal" data-babycode-tag="ul" data-break-line data-s="insertBabycode">&bullet;</button>
<button type="button" title="insert spoiler" class="minimal" data-babycode-tag="spoiler=" data-break-line data-prefill="spoiler content" data-s="insertBabycode">s</button>
<button type="button" title="insert emoji&hellip;" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
</span>
<span class="flex-last">{# stub: char count #}</span>
<span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">stub: char count</span>
</span>
<input type="hidden" name="babycode_banned_tags" id="{{id}}-banned-tags" value="{{banned_tags | unique | list | tojson | forceescape}}">
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}} autocomplete="off" maxlength="5000">{{ prefill }}</textarea>
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}} autocomplete="off" maxlength="5000" data-r="insertBabycode babycodePreviewInit babycodeEditorCharCountInit" data-listeners="input" data-s="babycodeEditorCharCount" data-banned-tags="{{banned_tags | unique | list | tojson | forceescape}}">{{ prefill }}</textarea>
{%- if banned_tags -%}
<div>
<span>Forbidden tags:</span>
@@ -123,6 +124,8 @@
</div>
{%- endif -%}
<a href="##">babycode help</a>
{%- else -%}
<div data-r="showBabycodePreview"></div>
{%- endif -%}
{%- endcall -%}
{%- endmacro %}
@@ -183,12 +186,12 @@
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id, _anchor='babycode-content')}}">Edit</a>
{%- endif -%}
{%- if can_reply -%}
<button disabled title="This feature requires JavaScript to be enabled.">Quote</button>
<button data-r="enhance" disabled title="This feature requires JavaScript to be enabled.">Quote</button>
{%- endif -%}
{%- if can_delete -%}
<a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a>
{%- endif -%}
<button disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark&hellip;</button>
<button data-r="enhance" disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark&hellip;</button>
</span>
{%- endif -%}
</div>
@@ -213,10 +216,10 @@
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
<button type="button" disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
<button data-r="enhance" type="button" disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
</span>
{%- if is_logged_in() and allow_reacting -%}<button disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
{%- if is_logged_in() and allow_reacting -%}<button data-r="enhance" disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
{%- elif is_editing -%}
<input type="submit" value="Save">
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>

View File

@@ -17,6 +17,7 @@
<input type="submit" value="Clear MOTD" class="warn">
</form>
</fieldset>
{{babycode_editor_component(placeholder='test', id='test-content')}}
<fieldset class="plank" id="sort-topics">
<legend>Sort topics</legend>
<p>Drag topics around to reorder them. Press "Save order" when done.</p>

View File

@@ -263,7 +263,7 @@ a.site-title {
--rotation: 180deg;
padding: var(--medium-padding) var(--huge-padding);
background: linear-gradient(var(--rotation), var(--lighter-color) 0%, var(--main-color) 30%, var(--main-color) 70%, var(--darker-color) 100%);
background: linear-gradient(var(--rotation), var(--lighter-color) 0%, var(--main-color) 40px, var(--main-color) calc(100% - 40px), var(--darker-color) 100%);
background-color: var(--main-color);
border: 2px groove var(--border-color);
@@ -905,6 +905,9 @@ ol.sortable-list {
}
}
.js-only {
display: none;
}
@media (max-width: 768px) {
body {
@@ -928,9 +931,9 @@ ol.sortable-list {
min-height: 140px;
}
.usercard-inner {
flex-direction: row;
justify-content: space-between;
.usercard-inner:has(.usercard-rest) {
display: grid;
grid-template-columns: min-content 1fr;
}
.thread-title-counter {

View File

@@ -0,0 +1,16 @@
export const b = {
init: 'enhance',
}
export function enhance(_, __, el) {
if (el.classList.contains('js-only')) {
el.classList.remove('js-only');
}
if (el.disabled) {
el.disabled = false;
if (el.title.search('JavaScript') !== -1) {
el.title = '';
}
}
}

164
data/static/js/bits/ui.js Normal file
View File

@@ -0,0 +1,164 @@
export const b = {
babycodePreviewEndpoint: '/api/babycode-preview/',
init: 'babycodeEditorCharCountInit',
}
export function setTab(_, sender, el) {
if (sender.ariaSelected === 'true') {
return;
}
if (!el.contains(sender)) {
return;
}
const tabIndex = parseInt(sender.dataset.tabIndex);
const tabPanels = el.querySelectorAll('.tab-content');
const tabButtons = el.querySelectorAll('.tab-bar button');
for (let i = 0; i < tabPanels.length; i++) {
const tabPanel = tabPanels[i];
const tabButton = tabButtons[i];
if (i === tabIndex) {
tabPanel.classList.remove('hidden');
tabButton.ariaSelected = 'true';
} else if (!tabPanel.classList.contains('hidden')) {
tabPanel.classList.add('hidden');
tabButton.ariaSelected = 'false';
}
}
}
export function insertBabycode(_, sender, el) {
if (!el.parentNode.contains(sender)) {
return;
}
const tagStart = sender.dataset.babycodeTag;
const breakLine = 'breakLine' in sender.dataset;
const prefill = 'prefill' in sender.dataset ? sender.dataset.prefill : '';
const hasAttr = tagStart[tagStart.length - 1] === '=';
let tagEnd = tagStart;
let tagInsertStart = `[${tagStart}]${breakLine ? '\n' : ''}`;
if (hasAttr) {
tagEnd = tagEnd.slice(0, -1);
}
const tagInsertEnd = `${breakLine ? '\n' : ''}[/${tagEnd}]`;
const hasSelection = el.selectionStart !== el.selectionEnd;
const text = el.value;
if (hasSelection) {
const realStart = Math.min(el.selectionStart, el.selectionEnd);
const realEnd = Math.max(el.selectionStart, el.selectionEnd);
const selectionLength = realEnd - realStart;
const strStart = text.slice(0, realStart);
const strEnd = text.substring(realEnd);
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
const reconst = `${strStart}${frag}${strEnd}`;
el.value = reconst;
if (!hasAttr) {
el.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertEnd.length + selectionLength - 1);
} else {
const attrCursor = realStart + tagInsertEnd.length - (1 + (breakLine ? 1 : 0))
el.setSelectionRange(attrCursor, attrCursor); // cursor on attr
}
} else {
if (hasAttr) {
tagInsertStart += prefill;
}
const cursor = el.selectionStart;
const strStart = text.slice(0, cursor);
const strEnd = text.substring(cursor);
let newCursor = strStart.length + tagInsertStart.length;
if (hasAttr) {
newCursor = cursor + tagInsertStart.length - prefill.length - (1 + (breakLine ? 1 : 0)); //cursor on attr
}
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
el.value = reconst;
el.setSelectionRange(newCursor, newCursor);
}
el.focus();
b.send({ sender: el }, 'babycodeEditorCharCount');
}
export function babycodeEditorCharCount(evOrPayload, sender, el) {
if (!sender) { // sent from bitty, not input
sender = evOrPayload.sender;
}
if (!sender.parentNode.contains(el)) {
return;
}
const maxLength = sender.maxLength;
const currentLength = sender.value.length;
el.innerText = `${currentLength}/${maxLength}`
}
export function babycodeEditorCharCountInit(_, __, el) {
b.send({ sender: el }, 'babycodeEditorCharCount');
}
export function babycodePreviewInit(ev, sender, el) {
if (!sender.parentNode.parentNode.contains(el)) { // tab container > tab bar > button
return;
}
b.send({ text: el.value, sender: sender, bannedTags: JSON.parse(el.dataset.bannedTags) }, 'babycodePreview');
}
export async function babycodePreview(payload, _, el) {
if (!payload.sender.parentNode.parentNode.contains(el)) {
return;
}
if (!payload.text.trim()) {
b.send({ plain: 'Type something to get a preview.', sender: el }, 'showBabycodePreview');
return;
}
const options = {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
markup: payload.text,
banned_tags: payload.bannedTags,
}),
}
const f = await fetch(b.babycodePreviewEndpoint, options);
try {
if (!f.ok) {
console.error(f);
let msg = '';
switch (f.status) {
case 429:
return;
default:
msg = '(Something went wrong. Try again later.)'
}
b.send({ plain: msg, sender: el }, 'showBabycodePreview');
return;
}
b.send({ ...(await f.json()), sender: el }, 'showBabycodePreview');
} catch (error) {
b.send({ plain: '(Something went wrong. Try again later.)', sender: el }, 'showBabycodePreview');
console.error(error);
return;
}
}
export function showBabycodePreview(payload, _, el) {
if (!payload.sender.parentNode.contains(el)) {
return;
}
if (payload.plain) {
el.innerHTML = `<p>${payload.plain}</p>`;
} else {
el.innerHTML = payload.html;
}
}

File diff suppressed because one or more lines are too long