Migrate to Quart #1

Merged
raoul merged 1 commits from migrate-to-quart into master 2023-07-27 00:46:12 +00:00
10 changed files with 123 additions and 68 deletions
Showing only changes of commit 48ea6756a1 - Show all commits

2
.flake8 Normal file
View File

@ -0,0 +1,2 @@
[flake8]
max-line-length = 120

2
.gitignore vendored
View File

@ -2,3 +2,5 @@ __pycache__
*.egg-info *.egg-info
*.sqlite *.sqlite
stickynotes.cfg stickynotes.cfg
build
dist

62
pyproject.toml Normal file
View File

@ -0,0 +1,62 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "StickyNotes"
dynamic = ["version"]
description = "A simple pastebin"
license = "GPL-3.0-or-later"
requires-python = ">=3.11"
authors = [
{ name = "Raoul Snyman", email = "raoul@snyman.info" },
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
# "Framework :: Quart",
"Intended Audience :: Other Audience",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System",
"Topic :: Internet :: WWW/HTTP :: WSGI",
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
]
dependencies = [
"Quart",
"Quart-Flask-Patch",
"Flask-SQLAlchemy",
"nord-pygments",
"psycopg2_binary",
"Pygments",
"requests",
"short_url",
]
[project.optional-dependencies]
dev = [
"pytest-cov",
"pytest",
]
[project.urls]
Homepage = "https://bin.snyman.info"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.sdist]
include = [
"/stickynotes",
]
[tool.hatch.envs.default.scripts]
server = "quart -A stickynotes.app run"

View File

@ -5,7 +5,8 @@ StickyNotes, yet another paste bin
from configparser import ConfigParser from configparser import ConfigParser
from pathlib import Path from pathlib import Path
from flask import Flask import quart_flask_patch # noqa: F401
from quart import Quart
from stickynotes.db import db from stickynotes.db import db
from stickynotes.views import views from stickynotes.views import views
@ -19,6 +20,8 @@ def read_config(config_path=None):
config_file = config_path / 'stickynotes.cfg' config_file = config_path / 'stickynotes.cfg'
else: else:
config_file = Path(__file__).parent / '..' / 'stickynotes.cfg' config_file = Path(__file__).parent / '..' / 'stickynotes.cfg'
if not config_file.exists():
return {}
config_parser = ConfigParser() config_parser = ConfigParser()
config_parser.read(config_file) config_parser.read(config_file)
config = {} config = {}
@ -31,13 +34,16 @@ def make_app(config_path=None):
""" """
Create the application object Create the application object
""" """
app = Flask(__name__) app = Quart(__name__)
# Load the config file # Load the config file
config = read_config(config_path) config = read_config(config_path)
app.config.update(config) app.config.update(config)
app.config.update({'SQLALCHEMY_TRACK_MODIFICATIONS': False}) app.config.update({'SQLALCHEMY_TRACK_MODIFICATIONS': False})
db.init_app(app) db.init_app(app)
with app.app_context():
db.create_all()
app.register_blueprint(views) app.register_blueprint(views)
@app.before_first_request
async def setup_db():
db.create_all()
return app return app

3
stickynotes/__main__.py Normal file
View File

@ -0,0 +1,3 @@
from stickynotes.app import application
application.run()

View File

@ -5,7 +5,7 @@ This is the entry point for the WSGI server
from pathlib import Path from pathlib import Path
from stickynotes import make_app from stickynotes import make_app
application = make_app(Path(__file__).parent) application = make_app(Path(__file__).parent.parent)
if __name__ == '__main__': if __name__ == '__main__':
application.run(debug=True) application.run(debug=True)

View File

@ -65,3 +65,9 @@ td.linenos pre {
.form-control:focus { .form-control:focus {
color: var(--white); color: var(--white);
} }
.computer {
display: none;
left: -9000000000px;
position: absolute;
}

View File

@ -3,7 +3,7 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-sm-12 col-md-12 col-lg-12"> <div class="col-xs-12 col-sm-12 col-md-12 col-lg-12">
<h2>About StickyNotes</h2> <h2>About StickyNotes</h2>
<p>StickyNotes is a quick code paste application written in Python with Flask, SQLAlchemy, Mako, Pygments and a few other Python libraries.</p> <p>StickyNotes is a quick code paste application written in Python with Quartz, SQLAlchemy, Mako, Pygments and a few other Python libraries.</p>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -29,17 +29,10 @@
<option value="1m">1 Month</option> <option value="1m">1 Month</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group computer">
<div class="custom-control custom-checkbox"> <label for="computer">Computer? (just type "no")</label>
<input type="checkbox" name="private" class="custom-control-input" id="private"> <input type="text" name="computer" class="form-control" id="computer" autocomplete="off" placeholder="Computers type yes">
<label for="private" class="custom-control-label">Unlisted (doesn't appear in the list on the notes page)</label>
</div>
</div> </div>
{% if recaptcha_site_key %}
<div class="form-group">
<div class="g-recaptcha" data-sitekey="{{recaptcha_site_key}}"></div>
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Submit</button> <button type="submit" class="btn btn-primary">Submit</button>
</form> </form>
</div> </div>

View File

@ -3,11 +3,11 @@
The views The views
""" """
import logging import logging
import secrets
import string
from datetime import timedelta, datetime from datetime import timedelta, datetime
import requests from quart import Blueprint, redirect, request, flash, make_response, current_app, render_template
import short_url
from flask import Blueprint, redirect, request, flash, make_response, current_app, render_template
from pygments import highlight from pygments import highlight
from pygments.formatters.html import HtmlFormatter from pygments.formatters.html import HtmlFormatter
from pygments.lexers import get_lexer_by_name, get_all_lexers from pygments.lexers import get_lexer_by_name, get_all_lexers
@ -30,34 +30,28 @@ EXPIRY_DELTAS = {
} }
def _is_recaptcha_valid(secret, response, remote_ip=None): def _generate_short_url():
""" """
POST to the recaptcha service to check if the recaptcha is valid Encode the URL
""" """
data = {'secret': secret, 'response': response} alphabet = string.ascii_lowercase + string.digits
if remote_ip: short_url = ''.join(secrets.choice(alphabet) for _ in range(8))
data['remoteip'] = remote_ip return short_url
response = requests.post('https://www.google.com/recaptcha/api/siteverify', data=data)
try:
json_response = response.json()
return json_response['success']
except ValueError:
return False
@views.route('/', methods=['GET']) @views.route('/', methods=['GET'])
def index(): async def index():
""" """
Add a new sticky note Add a new sticky note
""" """
all_lexers = [(lexer[1][0], lexer[0]) for lexer in get_all_lexers() if len(lexer) > 1 and len(lexer[1]) > 0] all_lexers = [(lexer[1][0], lexer[0]) for lexer in get_all_lexers() if len(lexer) > 1 and len(lexer[1]) > 0]
all_lexers.sort(key=lambda x: x[1].lower()) all_lexers.sort(key=lambda x: x[1].lower())
recaptcha_site_key = current_app.config.get('RECAPTCHA_SITE_KEY') recaptcha_site_key = current_app.config.get('RECAPTCHA_SITE_KEY')
return render_template('index.html', lexers=all_lexers, recaptcha_site_key=recaptcha_site_key) return await render_template('index.html', lexers=all_lexers, recaptcha_site_key=recaptcha_site_key)
@views.route('/notes', methods=['GET']) @views.route('/notes', methods=['GET'])
def notes(): async def notes():
""" """
Show a list of recent notes Show a list of recent notes
""" """
@ -66,54 +60,41 @@ def notes():
.filter(~StickyNote.private)\ .filter(~StickyNote.private)\
.order_by(StickyNote.created.desc())\ .order_by(StickyNote.created.desc())\
.limit(10) # noqa .limit(10) # noqa
return render_template('notes.html', notes=notes) return await render_template('notes.html', notes=notes)
@views.route('/about', methods=['GET']) @views.route('/about', methods=['GET'])
def about(): async def about():
""" """
Show the about page Show the about page
""" """
return render_template('about.html') return await render_template('about.html')
@views.route('/', methods=['POST']) @views.route('/', methods=['POST'])
def save(): async def save():
""" """
Save a sticky note Save a sticky note
""" """
# Check if the recaptcha is valid form = await request.form
recaptcha_secret_key = current_app.config.get('RECAPTCHA_SECRET_KEY') if form.get('computer') and form['computer'].lower() != 'no':
if recaptcha_secret_key:
is_recaptcha_valid = False
try:
is_recaptcha_valid = _is_recaptcha_valid(recaptcha_secret_key, request.form['g-recaptcha-response'])
except KeyError:
flash('Unable to verify you, don\'t forget to complete the captcha.', 'danger')
print('No form variable')
else:
is_recaptcha_valid = True
if not is_recaptcha_valid:
return redirect('/') return redirect('/')
# Save the note # Save the note
try: try:
created = datetime.utcnow() created = datetime.utcnow()
expiry = EXPIRY_DELTAS.get(request.form['expiry']) expiry = created + EXPIRY_DELTAS.get(form['expiry'], EXPIRY_DELTAS['1d'])
if expiry: # Generate a short url, and check if it exists in the db
expiry = created + expiry url = _generate_short_url()
source = request.form['source'] while StickyNote.query.filter(StickyNote.url == url).first():
lexer = request.form['language'] url = _generate_short_url()
title = request.form.get('title', '') # Create a new note
private = True if request.form.get('private') else False
string_id = ''.join([source, lexer, title, created.isoformat()])
url = short_url.encode_url(sum([ord(char) for char in string_id]), min_length=8)
note = StickyNote( note = StickyNote(
title=title, title=form.get('title', ''),
source=source, source=form['source'],
lexer=lexer, lexer=form['language'],
created=created, created=created,
expiry=expiry, expiry=expiry,
private=private, private=True,
url=url url=url
) )
session.add(note) session.add(note)
@ -126,7 +107,7 @@ def save():
@views.route('/<string:note_url>', methods=['GET']) @views.route('/<string:note_url>', methods=['GET'])
def view(note_url): async def view(note_url):
""" """
Show a sticky note Show a sticky note
@ -140,11 +121,11 @@ def view(note_url):
lexer = get_lexer_by_name(note.lexer) lexer = get_lexer_by_name(note.lexer)
formatter = HtmlFormatter(linenos=True, cssclass='source') formatter = HtmlFormatter(linenos=True, cssclass='source')
result = highlight(note.source, lexer, formatter) result = highlight(note.source, lexer, formatter)
return render_template('view.html', note=note, source=result) return await render_template('view.html', note=note, source=result)
@views.route('/raw/<string:note_url>', methods=['GET']) @views.route('/raw/<string:note_url>', methods=['GET'])
def raw(note_url): async def raw(note_url):
""" """
Show the raw version of a sticky note Show the raw version of a sticky note
@ -154,14 +135,14 @@ def raw(note_url):
if not note: if not note:
flash('That note does not exist', 'danger') flash('That note does not exist', 'danger')
return redirect('/') return redirect('/')
return render_template('raw.html', source=note.source), 200, {'Content-Type': 'text/plain; charset=utf-8'} return await render_template('raw.html', source=note.source), 200, {'Content-Type': 'text/plain; charset=utf-8'}
@views.route('/pygments.css', methods=['GET']) @views.route('/pygments.css', methods=['GET'])
def pygments_css(): async def pygments_css():
""" """
Return the Pygments CSS to the browser Return the Pygments CSS to the browser
""" """
response = make_response(HtmlFormatter(style='nord').get_style_defs()) response = await make_response(HtmlFormatter(style='nord').get_style_defs())
response.headers['Content-Type'] = 'text/css' response.headers['Content-Type'] = 'text/css'
return response return response