diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | README-bgstack15.md | 6 | ||||
-rw-r--r-- | config.cfg.tpl | 13 | ||||
-rw-r--r-- | initdb.py | 1 | ||||
-rwxr-xr-x[-rw-r--r--] | pastebin.py | 83 | ||||
-rwxr-xr-x | stackbin.bin | 9 | ||||
-rw-r--r-- | stackbin.wsgi.ini | 14 | ||||
-rw-r--r-- | static/style.css | 1 | ||||
-rw-r--r-- | templates/admin.html | 6 | ||||
-rw-r--r-- | templates/new_paste.html | 7 | ||||
-rw-r--r-- | templates/show_paste.html | 4 |
11 files changed, 134 insertions, 12 deletions
@@ -10,3 +10,5 @@ env pastebin_uwsgi.ini config.cfg .*.swp +log/ +*.pid diff --git a/README-bgstack15.md b/README-bgstack15.md index e0cd9bc..67b143e 100644 --- a/README-bgstack15.md +++ b/README-bgstack15.md @@ -24,17 +24,19 @@ Run server in development mode. FLASK_APP=pastebin.py FLASK_DEBUG=True flask run --host='0.0.0.0' +Run the server in a full wsgi environment for the cleanup timer to operate. + + ./stackbin.bin + # Improvements I still need to work on these tasks: ## Development * Protect the /admin/ page -* Add expiry of pastes? (use existing pubdate value, or just an extra column with desired expiration timestamp) ## Release -* Read any of my flask projects (fuss is the best one) to learn how to setup prod server * Document centos7 dependencies * Deploy to prod diff --git a/config.cfg.tpl b/config.cfg.tpl index 50b4419..23fb072 100644 --- a/config.cfg.tpl +++ b/config.cfg.tpl @@ -2,3 +2,16 @@ DEBUG=False SQLALCHEMY_DATABASE_URI='sqlite:///pastebin.db' SECRET_KEY='development-key' SALT='jackson' +DELETESALT='differentstring' +APPNAME='stackbin' +# LOOP_DELAY in seconds is how many seconds between running the expiration cleanup task +LOOP_DELAY = 5 * 3 + +# Disable this variable entirely, to disable any choices for expiration +# Any very simple expression for relative time can be used here. This will be processed by pyparsedate. +EXPIRATION_OPTIONS = [ + "never", + "1 day", + "1 hour", + "15 minutes" +] @@ -1,2 +1,3 @@ from pastebin import db +no_wsgi = True db.create_all() diff --git a/pastebin.py b/pastebin.py index 3851d01..d06dc6e 100644..100755 --- a/pastebin.py +++ b/pastebin.py @@ -1,7 +1,14 @@ -from datetime import datetime +from datetime import datetime, timedelta from itsdangerous import Signer from flask import (Flask, request, url_for, redirect, g, render_template, session, abort) from flask_sqlalchemy import SQLAlchemy +from pytimeparse.timeparse import timeparse # python3-pytimeparse +# uwsgidecorators load will fail when using initdb.py but is also not necessary +try: + from uwsgidecorators import timer # python3-uwsgidecorators +except: + pass +import time ## ripped from https://stackoverflow.com/questions/183042/how-can-i-use-uuids-in-sqlalchemy/812363#812363 from sqlalchemy import types @@ -12,6 +19,7 @@ class UUID(types.TypeDecorator): impl = MSBinary def __init__(self): self.impl.length = 16 + self.cache_ok = False # to shut up some useless warning types.TypeDecorator.__init__(self,length=self.impl.length) def process_bind_param(self,value,dialect=None): if value and isinstance(value,uuid.UUID): @@ -66,17 +74,27 @@ class Paste(db.Model): code = db.Column(db.Text) title = db.Column(db.Text) pub_date = db.Column(db.DateTime) + exp_date = db.Column(db.DateTime) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) is_private = db.Column(db.Boolean) parent_id = db.Column(UUID(), db.ForeignKey('paste.id')) parent = db.relationship('Paste', lazy=True, backref='children', uselist=False, remote_side=[id]) - def __init__(self, user, code, title, parent=None, is_private=False): + def __init__(self, user, code, title, relative_expiration_seconds, parent=None, is_private=False): self.user = user self.code = code self.title = title self.is_private = is_private - self.pub_date = datetime.utcnow() + u = datetime.utcnow() + try: + # this will fail on POSTed value of "never" for exp + b = timedelta(seconds=relative_expiration_seconds) + except: + # so timedelta() will return 0 seconds, which makes the exp_date = pub_date, which is treated + # by the cleanup logic and jinja2 templates as never-expires. + b = timedelta() + self.pub_date = u + self.exp_date = u + b self.parent = parent class User(db.Model): @@ -99,13 +117,62 @@ def new_paste(): title = "Untitled paste" if request.form['pastetitle'] and request.form['pastetitle'] != "Enter title here": title = request.form['pastetitle'] - paste = Paste(g.user, request.form['code'], title, parent=parent, is_private=is_private) + relative_expiration_seconds = 0 + exp = 0 # start with an empty value + if 'exp' in request.form and request.form['exp']: + exp_opt = request.form['exp'] + if exp_opt not in app.config['EXPIRATION_OPTIONS']: + try: + exp = timeparse(f"+ {app.config['EXPIRATION_OPTIONS'][0]}") + except: + exp = 60 * 60 # failsafe, 1 hour + print(f"WARNING: requested expiration \"{exp_opt}\" is not in the list of options {app.config['EXPIRATION_OPTIONS']}, so will use {exp}") + else: + try: + exp = timeparse(f"+ {exp_opt}") + except: + exp = 0 + paste = Paste( + g.user, + request.form['code'], + title, + exp, + parent=parent, + is_private=is_private + ) db.session.add(paste) db.session.commit() sign = get_signed(paste.id, salt=app.config['SALT']) \ if is_private else None return redirect(url_for('show_paste', paste_id=paste.id, s=sign)) - return render_template('new_paste.html', parent=parent) + try: + exp_opts = app.config['EXPIRATION_OPTIONS'] + except: + exp_opts = None + return render_template('new_paste.html', parent=parent, exp_opts = exp_opts) + +# This @timer is from the uwsgidecorators +try: + @timer(app.config['LOOP_DELAY']) + def cleanup_expired_pastes(num): + # num is useless. + """ + Every LOOP_DELAY seconds, find any entries that have expired and then delete them. + """ + all1 = Paste.query.all() + need_commit = False + for p in all1: + # the exp_date != pub_date is very important, because anything with "never" expires + # is stored in the db as exp_date = pub_date + if p.exp_date and p.exp_date != p.pub_date and p.exp_date <= datetime.utcnow(): + print(f"INFO: deleting paste \"{p.title}\" with expiration {p.exp_date}.") + Paste.query.filter(Paste.id == p.id).delete() + need_commit = True + # only run the commit once! + if need_commit: + db.session.commit() +except: + pass @app.route('/<paste_id>/') @app.route('/<paste_id>') @@ -188,6 +255,8 @@ def get_all_pastes(): p2 = { "id": p1.id, "title": p1.title, + "pub_date": p1.pub_date, + "exp_date": p1.exp_date, "private": private, "user_id": p1.user_id, "is_private": p1.is_private, @@ -203,3 +272,7 @@ def get_all_pastes(): def admin(): all_pastes = get_all_pastes() return render_template('admin.html', pastes = all_pastes) + +if __name__ == "__main__": + manager.add_command('runserver', Server(host=app.config["APP_HOST"], port=app.config["APP_PORT"])) + app.run() diff --git a/stackbin.bin b/stackbin.bin new file mode 100755 index 0000000..d3b2da3 --- /dev/null +++ b/stackbin.bin @@ -0,0 +1,9 @@ +#!/bin/sh +# Reference: fuss.bin from fuss project +# Startdate: 2022-02-13 19:25 +# Goal: see if uwsgi reacts to the @timer directives in the pastebin.py file, because flask run doesn't. +thisscript="$( readlink -f "${0}" )" +COMMAND="" +grep -qiE 'ID=.*(rhel|centos|fedora)' /etc/os-release && COMMAND="${COMMAND} uwsgi" || \ + COMMAND="${COMMAND} uwsgi_python39" +${COMMAND} --ini "$( dirname "${thisscript}" )/$( basename "${thisscript}" | sed -r -e 's/\.bin$//;' ).wsgi.ini" diff --git a/stackbin.wsgi.ini b/stackbin.wsgi.ini new file mode 100644 index 0000000..cac4511 --- /dev/null +++ b/stackbin.wsgi.ini @@ -0,0 +1,14 @@ +[uwsgi] +plugins = logfile +http-socket = 0.0.0.0:5000 +wsgi-file = pastebin.py +callable = app +touch-reload = pastebin.py +touch-reload = config.cfg +touch-reload = stackbin.wsgi.ini +req-logger = file:log/req.log +# to get strftime format fields, you need double percent signs +logdate = "%%FT%%T" +logger = file:log/stackbin.log +# the init script uses a different pidfile owned by root. +pidfile = stackbin.pid diff --git a/static/style.css b/static/style.css index 4507323..874cadf 100644 --- a/static/style.css +++ b/static/style.css @@ -9,6 +9,7 @@ a:hover { color: #c00; } .nav li + li:before { content: " // "; } h2 { font-weight: normal; color: #222; margin-bottom: 10px; padding-left: 10px} .chk-private { float: right; font-size: 12px; margin-top: 12px} +.drop-expiration { float: left; font-size: 12px; margin-top: 12px} dl { overflow: auto; font-size: 14px; padding: 0 10px} dl dt { font-weight: bold; min-width: 70px; float: left; padding-right: 15px; clear: left; } diff --git a/templates/admin.html b/templates/admin.html index a815fb5..4991d7c 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -4,13 +4,15 @@ <h1>Administration for {{ appname }}</h1> {% if pastes %} <table> -<tr><th>id</th><th>private</th><th>title</th><th>user</th><th>parent</th><th>children</th><th>Actions</th></tr> +<tr>{#<th>id</th>#}<th>private</th><th>title</th><th>user</th><th>published</th><th>expires</th><th>parent</th><th>children</th><th>Actions</th></tr> {% for p in pastes %} <tr> -<td>{{ p.id }}</td> +{# <td>{{ p.id }}</td> #} <td>{% if p.private %}✓{% endif %}</td>{# magic string is from utf8icons.com #} <td><a href="{% if not p.private %}{{ url_for('show_paste', paste_id=p.id) }}{% else %}{{ url_for('show_paste', paste_id=p.id, s=p.private) }}{% endif %}">{{ p.title }}</a></td> <td>{% if p.user %}{{ p.user }}{% endif%}</td> +<td>{{ p.pub_date.strftime('%FT%TZ') }}</td> +<td>{% if p.exp_date != p.pub_date %}{{ p.exp_date.strftime('%FT%TZ') }}{% endif %}</td> <td>{% if p.parent[0] %}<a href="{{ url_for('show_paste', paste_id=p.parent[0]) }}">{{ p.parent[1] }}</a>{% endif %}</td> <td>{% if p.children %}{% for c in p.children %}{% if not loop.first %},{% endif %} <a href="{{ url_for('show_paste', paste_id=c[0]) }}">{{ c[1] }}</a>{% endfor %}{% endif %} diff --git a/templates/new_paste.html b/templates/new_paste.html index 977ef22..b5a78aa 100644 --- a/templates/new_paste.html +++ b/templates/new_paste.html @@ -2,11 +2,14 @@ {% block title %}New Paste{% endblock %} {% block body %} <form action="" method=post> - <h2><div class="pastetitle"><textarea rows="1" name="pastetitle">Enter title here</textarea></div> + <h2><div class="pastetitle"><textarea rows="1" name="pastetitle" placeholder="Untitled paste"></textarea></div> {%- if parent %} - Reply to {{ parent.title }} {%- endif %} - <span class="chk-private"><input name="is_private" type="checkbox"/>Private</span> + <span class="chk-private"><input name="is_private" id="is_private" type="checkbox"/><label for="is_private">Private</label></span> + {% if exp_opts %}<span class="drop-expiration"><label for="exp">Expires:</label><select name="exp" id="exp"> + {% for o in exp_opts %}<option value="{{ o }}">{{ o }}</option>{% endfor %} + </select></span>{% endif %} </h2> <div class=code><textarea name=code cols=60 rows=12>{{ parent.code }}</textarea></div> <p><input type=submit value="New Paste"> diff --git a/templates/show_paste.html b/templates/show_paste.html index 03d57b1..e9ed02a 100644 --- a/templates/show_paste.html +++ b/templates/show_paste.html @@ -9,7 +9,9 @@ <dd>{{ paste.user.display_name }} {% endif %} <dt>Date - <dd>{{ paste.pub_date.strftime('%Y-%m-%dT%H:%M:%SZ') }} + <dd>{{ paste.pub_date.strftime('%FT%TZ') }} + {% if paste.exp_date and paste.exp_date != paste.pub_date %}<dt>Expires + <dd>{{ paste.exp_date.strftime('%FT%TZ') }}{% endif %} <dt>Actions <dd> {% if not paste.is_private %} |