diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | README.md | 7 | ||||
-rw-r--r-- | debian/changelog | 7 | ||||
-rw-r--r-- | debian/stackbin.dsc | 6 | ||||
-rw-r--r-- | extra/stackbin.spec | 5 | ||||
-rw-r--r-- | stackbin.conf.dev | 28 | ||||
-rw-r--r-- | stackbin.conf.example | 3 | ||||
-rw-r--r-- | stackbin.py | 126 | ||||
-rw-r--r-- | stackbin.wsgi.ini.dev | 23 |
9 files changed, 166 insertions, 40 deletions
@@ -19,3 +19,4 @@ debian/*.substvars debian/fuss/ debian/stackbin/ .pc +var/ @@ -47,6 +47,13 @@ This means that if your app is behind `http://example.com/stackbin/` then you wo http://example.com/stackbin/set +### Command line + +The application is designed primarily to be used with the web form, however, users can send data from the command line with curl. + + $ apt-cache show putty | curl https://example.com/stackbin/ -X POST --data-raw '@-' + https://example.com/stackbin/a2ee1780-f4c3-4672-bd47-e6ff0334b215 + ## Dependencies For a production stack on CentOS 7: diff --git a/debian/changelog b/debian/changelog index 67536b3..c37c16a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +stackbin (0.0.2-1) obs; urgency=low + + * New upstream release + * Add support to upload from command line + + -- B. Stack <bgstack15@gmail.com> Thu, 17 Mar 2022 11:31:40 -0400 + stackbin (0.0.1-1) obs; urgency=low * Initial release. Closes: packages-want#005 diff --git a/debian/stackbin.dsc b/debian/stackbin.dsc index 4a3f24a..75c2267 100644 --- a/debian/stackbin.dsc +++ b/debian/stackbin.dsc @@ -2,7 +2,7 @@ Format: 3.0 (quilt) Source: stackbin Binary: stackbin Architecture: all -Version: 0.0.1-1 +Version: 0.0.2-1 Maintainer: B. Stack <bgstack15@gmail.com> Homepage: https://bgstack15.ddns.net/ Standards-Version: 4.5.0 @@ -10,5 +10,5 @@ Build-Depends: debhelper-compat (= 12) Package-List: stackbin deb web optional arch=all Files: - 00000000000000000000000000000000 1 stackbin_0.0.1.orig.tar.gz - 00000000000000000000000000000000 1 stackbin_0.0.1-1.debian.tar.xz + 00000000000000000000000000000000 1 stackbin.orig.tar.gz + 00000000000000000000000000000000 1 stackbin.debian.tar.xz diff --git a/extra/stackbin.spec b/extra/stackbin.spec index 9ae510e..cd0d115 100644 --- a/extra/stackbin.spec +++ b/extra/stackbin.spec @@ -34,7 +34,7 @@ Summary: Pastebin implementation in flask Name: stackbin -Version: 0.0.1 +Version: 0.0.2 Release: 1 License: GPL 3.0 Source0: https://gitlab.com/bgstack15/%{name}/-/archive/master/%{name}-master.tar.gz @@ -154,5 +154,8 @@ exit 0 %{_defaultdocdir}/%{name} %changelog +* Thu Mar 17 2022 B. Stack <bgstack15@gmail.com> - 0.0.1-1 +- New upstream release + * Tue Feb 15 2022 B. Stack <bgstack15@gmail.com> - 0.0.1-1 - Initial release diff --git a/stackbin.conf.dev b/stackbin.conf.dev new file mode 100644 index 0000000..e2fa21f --- /dev/null +++ b/stackbin.conf.dev @@ -0,0 +1,28 @@ +DEBUG=True +SQLALCHEMY_DATABASE_URI='sqlite:///stackbin.db' +SQLALCHEMY_TRACK_MODIFICATIONS = False +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 = 60 * 5 + +# 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" +] + +#TEMPLATE_FOLDER = "/usr/share/stackbin/templates" +#STATIC_FOLDER = "/usr/share/stackbin/static" + +# If you turn these off, the admin panel is entirely disabled. +ADMIN_USERNAME = 'admin' +ADMIN_PASSWORD = 'fluffycat10' + +# Because the app generates the url entirely from scratch for a curl POST, the protocol can get lost, so you can force https if you define this variable. +CURL_RESPONSE_PROTOCOL = "https" diff --git a/stackbin.conf.example b/stackbin.conf.example index 481a448..518af7c 100644 --- a/stackbin.conf.example +++ b/stackbin.conf.example @@ -23,3 +23,6 @@ STATIC_FOLDER = "/usr/share/stackbin/static" # If you turn these off, the admin panel is entirely disabled. ADMIN_USERNAME = 'admin' ADMIN_PASSWORD = 'fluffycat10' + +# Because the app generates the url entirely from scratch for a curl POST, the protocol can get lost, so you can force https if you define this variable. +CURL_RESPONSE_PROTOCOL = "https" diff --git a/stackbin.py b/stackbin.py index b5f774a..b245206 100644 --- a/stackbin.py +++ b/stackbin.py @@ -9,8 +9,10 @@ # 2014 ofshellohicy removed some features # 2016 su27 added some features # 2022 bgstack15 hard forked +# 2022-03-17 add support for command | curl -d '@-' # Reference: # fuss.py +# https://stackoverflow.com/questions/15974730/how-do-i-get-the-different-parts-of-a-flask-requests-url # Improve: # Dependencies: # dep-devuan: python3-pytimeparse, python3-uwsgidecorators, python3-flask, python3:any, uwsgi-core, uwsgi-plugin-python3 @@ -41,6 +43,7 @@ from sqlalchemy.schema import Column import uuid class UUID(types.TypeDecorator): impl = MSBinary + cache_ok = True def __init__(self): self.impl.length = 16 self.cache_ok = False # to shut up some useless warning @@ -147,6 +150,32 @@ class User(db.Model): db.session.add(self) db.session.commit() +def _calculate_expiration(config, request = None, default = 0): + """ + Given the Flask request, default expiration value, and app.config, calculate this specific expiration timestamp for a paste. This is called by new_paste(). + """ + exp = default + if 'exp' in request.form and request.form['exp']: + exp_opt = request.form['exp'] + if exp_opt not in config['EXPIRATION_OPTIONS']: + try: + exp = timeparse(f"+ {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 {config['EXPIRATION_OPTIONS']}, so will use {exp}") + else: + try: + exp = timeparse(f"+ {exp_opt}") + except: + exp = 0 + else: + # so if the exp was not in the request form, i.e., this was from curl -d '@-', then use first option + try: + exp = timeparse(f"+ {config['EXPIRATION_OPTIONS'][0]}") + except: + exp = 60 * 60 # failsafe, 1 hour + return exp + @app.route('/', methods=['GET', 'POST']) def new_paste(): parent = None @@ -156,39 +185,60 @@ def new_paste(): parent = Paste.query.get(uuid.UUID(reply_to)) except: parent = Paste.query.get(reply_to) - if request.method == 'POST' and request.form['code']: - is_private = bool(request.form.get('is_private') or False) - title = "Untitled paste" - if request.form['pastetitle'] and request.form['pastetitle'] != "Enter title here": - title = request.form['pastetitle'] - 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)) + if request.method == 'POST': + # This request.get_data() must be here in order for the else contents = to work. + # It is probably related to getting the data before checking for request.form or something. + request.get_data() + if 'code' in request.form and request.form['code']: + is_private = bool(request.form.get('is_private') or False) + title = "Untitled paste" + if request.form['pastetitle'] and request.form['pastetitle'] != "Enter title here": + title = request.form['pastetitle'] + relative_expiration_seconds = 0 + exp = _calculate_expiration(app.config, request, 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)) + else: + # form field 'code' was not present, so this is probably command line piped into curl + contents = request.get_data().decode('utf8') + first_line = contents.split('\n')[0] + # cli does not provide choices for expiration, so use the first option from the list in the app config. + exp = _calculate_expiration(app.config, request, 0) + paste = Paste( + g.user, + contents, + first_line, + exp, + parent = None, + is_private = False + ) + # the url_root makes it a complete url + db.session.add(paste) + db.session.commit() + # We cannot rely on a user having visited /set already, so let us parse the proxied values right now. + prefix = '' + host = request.url_root + if 'HTTP_X_FORWARDED_PREFIX' in request.environ and 'PREFIX' not in app.config: + prefix = ''.join(request.environ['HTTP_X_FORWARDED_PREFIX'].split(", ")) + if 'HTTP_X_FORWARDED_HOST' in request.environ: + #request.environ['wsgi.url_scheme'] tends to always be http + protocol = "https" + if 'CURL_RESPONSE_PROTOCOL' in app.config: + protocol = app.config['CURL_RESPONSE_PROTOCOL'] + host = protocol + "://" + request.environ['HTTP_X_FORWARDED_HOST'].split(", ")[0] + return host.strip('/') + prefix.rstrip('/') + url_for('show_paste', paste_id = paste.id) + "\n" + # and now back to the GET try: exp_opts = app.config['EXPIRATION_OPTIONS'] except: @@ -331,12 +381,16 @@ def favicon(): @app.route('/set') def get_proxied_path(): prefix = "/" + print(f"DEBUG: request.environ: {request.environ}") if 'HTTP_X_FORWARDED_PREFIX' in request.environ: - pl = len(dict(request.headers)["X-Forwarded-Host"].split(", ")) + hl = len(dict(request.headers)["X-Forwarded-Host"].split(", ")) + pl = len(dict(request.headers)["X-Forwarded-Prefix"].split(", ")) prefix = request.environ['HTTP_X_FORWARDED_PREFIX'] - app.wsgi_app = ProxyFix(app.wsgi_app,x_for=pl,x_host=pl,x_port=pl,x_prefix=pl,x_proto=pl) + app.wsgi_app = ProxyFix(app.wsgi_app,x_for=pl,x_host=hl,x_port=hl,x_prefix=pl,x_proto=pl) + app.config['PROXYFIX'] = pl + app.config['PREFIX'] = prefix # we can afford to use prefix because new_paste is the top-level endpoint of the whole app - message = refresh_string(1, prefix) + "OK" + message = refresh_string(1, (prefix + "/").replace("//","/")) + "OK" return message, 200 # stubs, to simplify any templates that ask url_for("login") diff --git a/stackbin.wsgi.ini.dev b/stackbin.wsgi.ini.dev new file mode 100644 index 0000000..7ed3421 --- /dev/null +++ b/stackbin.wsgi.ini.dev @@ -0,0 +1,23 @@ +[uwsgi] +# CentOS 7 uwsgi needs "python36" added to this list. +#plugins = logfile, python36 +# Devuan Ceres does not. +plugins = logfile +http-socket = 0.0.0.0:4680 +wsgi-file = stackbin.py +callable = app +touch-reload = stackbin.py +touch-reload = stackbin.conf +touch-reload = stackbin.wsgi.ini +touch-reload = templates/admin.html +touch-reload = templates/layout.html +touch-reload = templates/login_form.html +touch-reload = templates/new_paste.html +touch-reload = templates/_pagination.html +touch-reload = templates/show_paste.html +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 = var/stackbin-wsgi.pid |