aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorB. Stack <bgstack15@gmail.com>2022-02-13 21:47:21 -0500
committerB. Stack <bgstack15@gmail.com>2022-02-13 21:54:20 -0500
commit85df4b3dc87003ae738f2676a99b88a83f3ac05a (patch)
tree15b892a06b81451d83f73cf0f8822c9bf262c4af
parentadd redirects after delete, and simplify css calls (diff)
downloadstackbin-85df4b3dc87003ae738f2676a99b88a83f3ac05a.tar.gz
stackbin-85df4b3dc87003ae738f2676a99b88a83f3ac05a.tar.bz2
stackbin-85df4b3dc87003ae738f2676a99b88a83f3ac05a.zip
add expiry, and wsgi usage to support that
The flask dev server seems incapable of using the @timer decorator, so we need the whole wsgi implementation, for which I've added a script. Arbitrary expiration options are available in the config file.
-rw-r--r--.gitignore2
-rw-r--r--README-bgstack15.md6
-rw-r--r--config.cfg.tpl13
-rw-r--r--initdb.py1
-rwxr-xr-x[-rw-r--r--]pastebin.py83
-rwxr-xr-xstackbin.bin9
-rw-r--r--stackbin.wsgi.ini14
-rw-r--r--static/style.css1
-rw-r--r--templates/admin.html6
-rw-r--r--templates/new_paste.html7
-rw-r--r--templates/show_paste.html4
11 files changed, 134 insertions, 12 deletions
diff --git a/.gitignore b/.gitignore
index abccb3f..a2e3e4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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"
+]
diff --git a/initdb.py b/initdb.py
index b56872f..da4f05a 100644
--- a/initdb.py
+++ b/initdb.py
@@ -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 %}&#10003;{% 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 %}
bgstack15