Skip to content

Commit 65af4bf

Browse files
committed
Initial commit
0 parents  commit 65af4bf

9 files changed

+319
-0
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
venv/
2+
config.py
3+
.idea/

LICENSE

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
Copyright (c) 2019, Ryan Schmidt
2+
All Rights Reserved.
3+
4+
Redistribution and use in source and binary forms, with or without
5+
modification, are permitted provided that the following conditions are met:
6+
7+
1. Redistributions of source code must retain the above copyright notice, this
8+
list of conditions and the following disclaimer.
9+
10+
2. Redistributions in binary form must reproduce the above copyright notice,
11+
this list of conditions and the following disclaimer in the documentation
12+
and/or other materials provided with the distribution.
13+
14+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

app.py

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from sys import stderr
2+
import string
3+
import random
4+
from flask import Flask, request, redirect, jsonify, abort, render_template
5+
from pygments import highlight
6+
from pygments.lexers import Python3TracebackLexer
7+
from pygments.formatters import HtmlFormatter
8+
import db
9+
10+
app = Flask(__name__)
11+
12+
13+
def _wants_json():
14+
"""
15+
Determine if the user wants json or html output for APIs which can serve both.
16+
17+
We prefer sending html over json, and only allow json if the Accept header contains application/json
18+
and either lacks text/html or has application/json has higher quality than text/html.
19+
20+
:return: True if the user should be served json, False if the user should be served html
21+
"""
22+
if not request.accept_mimetypes.accept_json:
23+
return False # if json isn't allowed, don't serve it
24+
if not request.accept_mimetypes.accept_html:
25+
return True # json is allowed but html isn't => serve json
26+
# otherwise prefer to serve html over json, respecting the quality param on Accept header
27+
best = request.accept_mimetypes.best_match(("text/html", "application/json"))
28+
if best != "application/json":
29+
return False # html is of greater quality than json
30+
if request.accept_mimetypes.quality("text/html") == request.accept_mimetypes.quality("application/json"):
31+
return False # html is of equal quality to json, and we prefer html
32+
return True # json is of higher quality than html
33+
34+
35+
@app.route("/submit", methods=["POST"])
36+
def submit():
37+
data = request.get_json(silent=True)
38+
if not isinstance(data, dict) or not data.get("c", None):
39+
return jsonify({"status": "error", "error": "Invalid request"}), 400
40+
charset = string.ascii_letters + string.digits
41+
with db.DbConnection() as conn:
42+
slug = "".join(random.choice(charset) for _ in range(4))
43+
while len(slug) < 17:
44+
found = len(conn.execute("SELECT paste_slug FROM pastes WHERE paste_slug = %s", (slug,)))
45+
if found == 0:
46+
break
47+
slug += random.choice(charset)
48+
if len(slug) == 17:
49+
# stderr is logged so we can examine these events later
50+
print(f"Unable to get unique URL slug, tried {slug}", file=stderr)
51+
return jsonify({"status": "error", "error": "Unable to get unique URL slug"}), 500
52+
conn.execute("""INSERT INTO pastes (paste_slug, paste_expires, paste_type, paste_content)
53+
VALUES (%s, DATE_ADD(NOW(), INTERVAL 7 DAY), 'paste', %s)""", (slug, data["c"]))
54+
return jsonify({"status": "success", "url": request.url_root + slug})
55+
56+
@app.route("/<slug>")
57+
def get_paste(slug):
58+
if len(slug) > 16:
59+
if _wants_json():
60+
return jsonify({"status": "error", "error": "Invalid request"}), 400
61+
else:
62+
abort(400)
63+
with db.DbConnection() as conn:
64+
data = conn.fetch("SELECT * FROM pastes WHERE paste_slug = %s", (slug,))
65+
if not data:
66+
if _wants_json():
67+
return jsonify({"status": "error", "error": "Paste not found"}), 404
68+
else:
69+
abort(404)
70+
if data["paste_type"] == "redirect":
71+
redirect(data["paste_content"])
72+
if _wants_json():
73+
return jsonify({"status": "success", "data": data["paste_content"], "expires": data["paste_expires"]})
74+
return highlight(data["paste_content"],
75+
Python3TracebackLexer(),
76+
HtmlFormatter(full=True, linenos="table", lineanchors="l", anchorlinenos=True, wrapcode=True))
77+
78+
if __name__ == "__main__":
79+
app.run()

config.py.example

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Session key. Changing this will invalidate all existing admin sessions.
2+
# This should be random!
3+
SESSION_KEY = b""
4+
5+
# Database credentials, used to store paste info
6+
DB_HOST = "localhost" # Hostname of MySQL server or absolute path to unix socket
7+
DB_PORT = 3306 # Port of MySQL server (not used if DB_HOST points to a unix socket)
8+
DB_NAME = "" # Database name
9+
DB_USER = "some_username" # Database user, must have read/write access to db
10+
DB_PASS = "some_password" # Password for database user
11+
12+
# Notification (webhook) settings
13+
# If enabled, POST to this address with a payload indicating a new paste
14+
# was made. The POST body will be a JSON document detailed in webhooks.txt.
15+
WEBHOOK_ENABLE = False # Whether or not to enable the webhook
16+
WEBHOOK_URL = "" # URL to POST to
17+
# PEM encoded private key to sign webhook
18+
WEBHOOK_KEY = """
19+
"""
20+
21+
# OAuth (admin area) settings. Admins can view/manage all pastes and create short URL redirects
22+
OAUTH_ENABLE = False # Whether to enable the admin area. Requires OAuth login.
23+
OAUTH_ENDPOINT = "" # Base URL to wiki, not including index.php or api.php
24+
OAUTH_CONSUMER_KEY = "" # OAuth consumer key
25+
OAUTH_SECRET_KEY = "" # OAuth secret key
26+
27+
# ElasticSearch settings
28+
ELASTIC_ENABLE = False # Whether to send incoming pastes to ElasticSearch
29+
ELASTIC_URL = "" # URL to ES instance. Cloud ID also supported here
30+
ELASTIC_PORT = 0 # Port of ES instance
31+
ELASTIC_USER = "" # If ES requires auth, the username to use (optional)
32+
ELASTIC_PASS = "" # If ES requires auth, the password to use (optional)
33+
ELASTIC_INDEX = "" # Index name to use. The user must have ability to write to this index
34+
ELASTIC_PIPELINE = "" # Ingest node pipeline to use (optional)

cron.py

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import config
2+
3+
4+
def run():
5+
"""Run cron tasks (deleting expired stuff)"""
6+
pass
7+
8+
9+
if __name__ == "__main__":
10+
run()

db.py

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import mysql.connector
2+
from typing import Union, Dict, List, Optional, Tuple, Any
3+
4+
import config
5+
6+
7+
class DbConnection:
8+
def __init__(self):
9+
self.conn = None # type: Optional[mysql.connector.MySQLConnection]
10+
11+
def __enter__(self):
12+
args = {
13+
"user": config.DB_USER,
14+
"password": config.DB_PASS,
15+
"database": config.DB_NAME,
16+
"charset": "utf8mb4",
17+
"collation": "utf8mb4_unicode_ci"
18+
}
19+
20+
if config.DB_HOST[0] == "/":
21+
# using unix socket
22+
args["unix_socket"] = config.DB_HOST
23+
else:
24+
args["host"] = config.DB_HOST
25+
args["port"] = config.DB_PORT
26+
27+
self.conn = mysql.connector.connect(**args)
28+
return self
29+
30+
def __exit__(self, exc_type, exc_val, exc_tb):
31+
"""Close the db connection and commit/rollback any pending transactions.
32+
33+
If an exception was raised, we rollback. Otherwise, we commit.
34+
"""
35+
if self.conn and self.conn.is_connected():
36+
try:
37+
if exc_type is None:
38+
self.conn.commit()
39+
else:
40+
self.conn.rollback()
41+
finally:
42+
self.conn.close()
43+
self.conn = None # type: Optional[mysql.connector.MySQLConnection]
44+
45+
def execute(self, operation: Any, params: Tuple = (), multi: bool = False)\
46+
-> Union[int, List[Union[int, Dict[str, Any], List[Dict[str, Any]]]]]:
47+
c = self.conn.cursor(dictionary=True)
48+
try:
49+
result = c.execute(operation, params=params, multi=multi)
50+
if multi:
51+
data = []
52+
for r in result:
53+
if r.with_rows:
54+
data.append(r.fetchall())
55+
else:
56+
data.append(r.rowcount)
57+
else:
58+
if c.with_rows:
59+
data = c.fetchall()
60+
else:
61+
data = c.rowcount
62+
finally:
63+
c.close()
64+
return data
65+
66+
def fetch(self, operation: Any, params: Tuple = ()) -> Optional[Dict[str, Any]]:
67+
"""
68+
Fetches the first row of the result set, or None if there are no results.
69+
70+
Returns None if the operation is not a SELECT query as well. Use execute if you
71+
wish to retrieve the number of affected rows for a DML query.
72+
73+
:param operation: SQL query
74+
:param params: Parameters for the query
75+
:return: The first row or None
76+
"""
77+
data = self.execute(operation, params)
78+
if isinstance(data, list):
79+
try:
80+
return data[0]
81+
except IndexError:
82+
return None # no results
83+
return None # not a query that returned data, so return None

install.py

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from typing import Set
2+
import pathlib
3+
from db import DbConnection
4+
5+
6+
def run():
7+
"""
8+
Execute the installer.
9+
10+
The installer will get the database schema up to current as well as set up any necessary
11+
ancillary structures such as ElasticSearch indices and pipelines.
12+
13+
:return:
14+
"""
15+
with DbConnection() as conn:
16+
conn.execute("""CREATE TABLE IF NOT EXISTS migrations (
17+
migration_key varchar(150) PRIMARY KEY,
18+
migration_time datetime)""")
19+
applied_migrations = set()
20+
for row in conn.execute("SELECT migration_key FROM migrations"):
21+
applied_migrations.add(row["migration_key"])
22+
for p in sorted(pathlib.Path("migrations").iterdir()):
23+
if p.is_file() and p[-4:] == ".sql":
24+
_apply_migration(p.name[:-4], str(p.absolute()), conn, applied_migrations)
25+
26+
27+
def _apply_migration(name: str, path: str, conn: DbConnection, already_applied: Set[str]) -> None:
28+
"""
29+
Check if a migration is applied and if not, run it.
30+
31+
:param name: Migration name (key). This must be unique.
32+
:param path: Path to migration sql file.
33+
:param conn: Already-open database connection.
34+
:param already_applied: A set of which migrations have already been applied. The current migration will be
35+
added to this set if successful.
36+
:return:
37+
"""
38+
if name in already_applied:
39+
return
40+
41+
with open(path, "r") as f:
42+
conn.execute(f.read(), multi=True)
43+
already_applied.add(name)
44+
conn.execute("INSERT INTO migrations (migration_key, migration_time) VALUES (%s, NOW())",
45+
(name,))
46+
47+
48+
if __name__ == "__main__":
49+
run()

migrations/0001_initial_schema.sql

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
CREATE TABLE pastes (
2+
paste_id int PRIMARY KEY AUTO_INCREMENT,
3+
paste_slug varchar(16) NOT NULL COMMENT 'URL slug for the paste, must be unique',
4+
paste_expires datetime COMMENT 'When the paste expires, or NULL if it never expires',
5+
paste_type enum('paste', 'redirect') NOT NULL
6+
COMMENT 'What type of paste this is. If a paste, paste_content refers to the paste value itself. '
7+
'If a redirect, paste_content is a URL to redirect the user to.',
8+
paste_content text NOT NULL,
9+
CONSTRAINT pastes_paste_slug UNIQUE KEY (paste_slug),
10+
INDEX pastes_paste_expires (paste_expires)
11+
);

requirements.txt

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
asn1crypto==0.24.0
2+
certifi==2019.3.9
3+
cffi==1.12.3
4+
chardet==3.0.4
5+
Click==7.0
6+
cryptography==2.6.1
7+
elasticsearch==7.0.2
8+
Flask==1.0.3
9+
flask-mwoauth==0.3.70
10+
future==0.17.1
11+
idna==2.8
12+
itsdangerous==1.1.0
13+
Jinja2==2.10.1
14+
MarkupSafe==1.1.1
15+
mwoauth==0.3.3
16+
mysql-connector-python==8.0.16
17+
oauthlib==3.0.1
18+
protobuf==3.7.1
19+
pycparser==2.19
20+
Pygments==2.4.2
21+
PyJWT==1.7.1
22+
python-dateutil==2.8.0
23+
requests==2.22.0
24+
requests-oauthlib==1.2.0
25+
six==1.12.0
26+
urllib3==1.25.3
27+
Werkzeug==0.15.4

0 commit comments

Comments
 (0)