diff options
Diffstat (limited to 'hex-zero.py')
-rwxr-xr-x | hex-zero.py | 545 |
1 files changed, 545 insertions, 0 deletions
diff --git a/hex-zero.py b/hex-zero.py new file mode 100755 index 0000000..b6fcdd6 --- /dev/null +++ b/hex-zero.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# https://stackoverflow.com/a/34831345/3569534 + +""" + Copyright © 2020 Mia Herkt + Licensed under the EUPL, Version 1.2 or - as soon as approved + by the European Commission - subsequent versions of the EUPL + (the "License"); + You may not use this work except in compliance with the License. + You may obtain a copy of the license at: + + https://joinup.ec.europa.eu/software/page/eupl + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + either express or implied. + See the License for the specific language governing permissions + and limitations under the License. +""" + +from flask import Flask, abort, escape, make_response, redirect, request, send_from_directory, url_for, Response +from flask_sqlalchemy import SQLAlchemy +from flask_script import Manager, Server +from flask_migrate import Migrate, MigrateCommand +from hashlib import sha256 +from humanize import naturalsize +from magic import Magic +from mimetypes import guess_extension +import os, sys +import requests +from short_url import UrlEncoder +from validators import url as url_valid + +app = Flask(__name__) +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite" # "postgresql://0x0@/0x0" +app.config["PREFERRED_URL_SCHEME"] = "https" # nginx users: make sure to have 'uwsgi_param UWSGI_SCHEME $scheme;' in your config +app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 +app.config["MAX_URL_LENGTH"] = 4096 +app.config["FHOST_STORAGE_PATH"] = "up" +app.config["FHOST_USE_X_ACCEL_REDIRECT"] = True # expect nginx by default +app.config["USE_X_SENDFILE"] = False +app.config["FHOST_EXT_OVERRIDE"] = { + "audio/flac" : ".flac", + "image/gif" : ".gif", + "image/jpeg" : ".jpg", + "image/png" : ".png", + "image/svg+xml" : ".svg", + "video/webm" : ".webm", + "video/x-matroska" : ".mkv", + "application/octet-stream" : ".bin", + "text/plain" : ".log", + "text/plain" : ".txt", + "text/x-diff" : ".diff", +} + +# default blacklist to avoid AV mafia extortion +app.config["FHOST_MIME_BLACKLIST"] = [ + "application/x-dosexec", + "application/java-archive", + "application/java-vm" +] + +app.config["FHOST_UPLOAD_BLACKLIST"] = "tornodes.txt" + +app.config["NSFW_DETECT"] = False +app.config["NSFW_THRESHOLD"] = 0.608 + +# read config file +app.config.from_pyfile("hex-zero.conf", silent=True) + +# read html template file +with open(app.config["FHOST_FRONTPAGE"], "r") as j: + frontpagestring = j.read() + +if app.config["NSFW_DETECT"]: + from nsfw_detect import NSFWDetector + nsfw = NSFWDetector() + +try: + mimedetect = Magic(mime=True, mime_encoding=False) +except: + print("""Error: You have installed the wrong version of the 'magic' module. +Please install python-magic.""") + sys.exit(1) + +if not os.path.exists(app.config["FHOST_STORAGE_PATH"]): + os.mkdir(app.config["FHOST_STORAGE_PATH"]) + +db = SQLAlchemy(app) +migrate = Migrate(app, db) + +manager = Manager(app) +manager.add_command("db", MigrateCommand) + +su = UrlEncoder(alphabet='DEQhd2uFteibPwq0SWBInTpA_jcZL5GKz3YCR14Ulk87Jors9vNHgfaOmMXy6Vx-', block_size=16) + +class URL(db.Model): + id = db.Column(db.Integer, primary_key = True) + url = db.Column(db.UnicodeText, unique = True) + + def __init__(self, url): + self.url = url + + def getname(self): + return su.enbase(self.id, 1) + + def geturl(self): + return url_for("get", path=self.getname(), _external=True) + "\n" + +class File(db.Model): + id = db.Column(db.Integer, primary_key = True) + sha256 = db.Column(db.String, unique = True) + ext = db.Column(db.UnicodeText) + mime = db.Column(db.UnicodeText) + addr = db.Column(db.UnicodeText) + removed = db.Column(db.Boolean, default=False) + nsfw_score = db.Column(db.Float) + + def __init__(self, sha256, ext, mime, addr, nsfw_score): + self.sha256 = sha256 + self.ext = ext + self.mime = mime + self.addr = addr + self.nsfw_score = nsfw_score + + def getname(self): + return u"{0}{1}".format(su.enbase(self.id, 1), self.ext) + + def geturl(self): + n = self.getname() + o = n + + if self.nsfw_score and self.nsfw_score > app.config["NSFW_THRESHOLD"]: + o = url_for("get", path=n, _external=True, _anchor="nsfw") + "\n" + else: + o = url_for("get", path=n, _external=True) + "\n" + return o.replace("http://" + app.config["APP_HOST_LISTEN"] + ":" + str(app.config["APP_PORT"]),app.config["APP_URL"]) + + def pprint(self): + print("url: {}".format(self.getname())) + vals = vars(self) + + for v in vals: + if not v.startswith("_sa"): + print("{}: {}".format(v, vals[v])) + +def getpath(fn): + return os.path.join(app.config["FHOST_STORAGE_PATH"], fn) + +def fhost_url(scheme=None): + return app.config["APP_URL"] + if not scheme: + return url_for(".fhost", _external=True).rstrip("/") + else: + return url_for(".fhost", _external=True, _scheme=scheme).rstrip("/") + +def is_fhost_url(url): + return url.startswith(fhost_url()) or url.startswith(fhost_url("https")) + +def shorten(url): + # handler to convert gopher links to HTTP(S) proxy + gopher = "gopher://" + length = len(gopher) + if url[:length] == gopher: + url = "https://gopher.envs.net/{}".format(url[length:]) + + if len(url) > app.config["MAX_URL_LENGTH"]: + abort(414) + + if not url_valid(url) or is_fhost_url(url) or "\n" in url: + abort(400) + + existing = URL.query.filter_by(url=url).first() + + if existing: + return existing.geturl() + else: + u = URL(url) + db.session.add(u) + db.session.commit() + + return u.geturl() + +def in_upload_bl(addr): + if os.path.isfile(app.config["FHOST_UPLOAD_BLACKLIST"]): + with open(app.config["FHOST_UPLOAD_BLACKLIST"], "r") as bl: + check = addr.lstrip("::ffff:") + for l in bl.readlines(): + if not l.startswith("#"): + if check == l.rstrip(): + return True + + return False + +def store_file(f, addr): + if in_upload_bl(addr): + return "Your host is blocked from uploading files.\n", 451 + + data = f.stream.read() + digest = sha256(data).hexdigest() + existing = File.query.filter_by(sha256=digest).first() + + if existing: + if existing.removed: + return legal() + + epath = getpath(existing.sha256) + + if not os.path.exists(epath): + with open(epath, "wb") as of: + of.write(data) + + if existing.nsfw_score == None: + if app.config["NSFW_DETECT"]: + existing.nsfw_score = nsfw.detect(epath) + + os.utime(epath, None) + existing.addr = addr + db.session.commit() + + return existing.geturl() + else: + guessmime = mimedetect.from_buffer(data) + + if not f.content_type or not "/" in f.content_type or f.content_type == "application/octet-stream": + mime = guessmime + else: + mime = f.content_type + + if mime in app.config["FHOST_MIME_BLACKLIST"] or guessmime in app.config["FHOST_MIME_BLACKLIST"]: + abort(415) + + if mime.startswith("text/") and not "charset" in mime: + mime += "; charset=utf-8" + + ext = os.path.splitext(f.filename)[1] + + if not ext: + gmime = mime.split(";")[0] + + if not gmime in app.config["FHOST_EXT_OVERRIDE"]: + ext = guess_extension(gmime) + else: + ext = app.config["FHOST_EXT_OVERRIDE"][gmime] + else: + ext = ext[:8] + + if not ext: + ext = ".bin" + + spath = getpath(digest) + + with open(spath, "wb") as of: + of.write(data) + + if app.config["NSFW_DETECT"]: + nsfw_score = nsfw.detect(spath) + else: + nsfw_score = None + + sf = File(digest, ext, mime, addr, nsfw_score) + db.session.add(sf) + db.session.commit() + + return sf.geturl() + +def store_url(url, addr): + # handler to convert gopher links to HTTP(S) proxy + gopher = "gopher://" + length = len(gopher) + if url[:length] == gopher: + url = "https://gopher.envs.net/{}".format(url[length:]) + + if is_fhost_url(url): + return segfault(508) + + h = { "Accept-Encoding" : "identity" } + r = requests.get(url, stream=True, verify=False, headers=h) + + try: + r.raise_for_status() + except requests.exceptions.HTTPError as e: + return str(e) + "\n" + + if "content-length" in r.headers: + l = int(r.headers["content-length"]) + + if l < app.config["MAX_CONTENT_LENGTH"]: + def urlfile(**kwargs): + return type('',(),kwargs)() + + f = urlfile(stream=r.raw, content_type=r.headers["content-type"], filename="") + + return store_file(f, addr) + else: + hl = naturalsize(l, binary = True) + hml = naturalsize(app.config["MAX_CONTENT_LENGTH"], binary=True) + + return "Remote file too large ({0} > {1}).\n".format(hl, hml), 413 + else: + return "Could not determine remote file size (no Content-Length in response header; shoot admin).\n", 411 + +@app.route("/<path:path>") +def get(path): + p = os.path.splitext(path) + id = su.debase(p[0]) + + if p[1]: + f = File.query.get(id) + + if f and f.ext == p[1]: + if f.removed: + return legal() + + fpath = getpath(f.sha256) + + if not os.path.exists(fpath): + abort(404) + + fsize = os.path.getsize(fpath) + + if app.config["FHOST_USE_X_ACCEL_REDIRECT"]: + response = make_response() + response.headers["Content-Type"] = f.mime + response.headers["Content-Length"] = fsize + response.headers["X-Accel-Redirect"] = "/" + fpath + return response + else: + return send_from_directory(app.config["FHOST_STORAGE_PATH"], f.sha256, mimetype = f.mime) + else: + u = URL.query.get(id) + + if u: + return redirect(u.url) + + abort(404) + +@app.route("/dump_files/") +@app.route("/dump_files/<int:start>") +def dump_files(start=0): + meta = "#FORMAT: BEACON\n#PREFIX: {}/\n\n".format(fhost_url("https")) + + def gen(): + yield meta + + for file in File.query.order_by(File.id.asc()).offset(start): + yield file.getname() + "|" + str(file.id) + "\n" + + return Response(gen(), mimetype="text/plain") + +@app.route("/dump_urls/") +@app.route("/dump_urls/<int:start>") +def dump_urls(start=0): + meta = "#FORMAT: BEACON\n#PREFIX: {}/\n\n".format(fhost_url("https")) + + def gen(): + yield meta + + for url in URL.query.order_by(URL.id.asc()).offset(start): + if url.url.startswith("http") or url.url.startswith("https"): + bar = "|" + else: + bar = "||" + + yield url.getname() + bar + url.url + "\n" + + return Response(gen(), mimetype="text/plain") + +@app.route("/", methods=["GET", "POST"]) +def fhost(): + if request.method == "POST": + out = None + + if "file" in request.files: + if app.config["USE_HTTP_X_FORWARDED_FOR"]: + stored_ip_address = request.environ["HTTP_X_FORWARDED_FOR"] + else: + stored_ip_address = request.remote_addr + out = store_file(request.files["file"], stored_ip_address) + elif "url" in request.form: + out = store_url(request.form["url"], request.remote_addr) + elif "shorten" in request.form: + out = shorten(request.form["shorten"]) + + if not out == None: + return Response(out, mimetype="text/plain") + + abort(400) + else: + fmts = list(app.config["FHOST_EXT_OVERRIDE"]) + fmts.sort() + maxsize = naturalsize(app.config["MAX_CONTENT_LENGTH"], binary=True) + maxsizenum, maxsizeunit = maxsize.split(" ") + maxsizenum = float(maxsizenum) + maxsizehalf = maxsizenum / 2 + + if maxsizenum.is_integer(): + maxsizenum = int(maxsizenum) + if maxsizehalf.is_integer(): + maxsizehalf = int(maxsizehalf) + + # python-interpreted variables + return frontpagestring.format(fhost_url(), + maxsize, str(maxsizehalf).rjust(27), str(maxsizenum).rjust(27), + maxsizeunit.rjust(54), + ", ".join(app.config["FHOST_MIME_BLACKLIST"]),fhost_url().split("/",2)[2], + app.config["APP_URL"], app.config["ADMIN_EMAIL"]) + +@app.route("/robots.txt") +def robots(): + return """User-agent: * +Disallow: / +""" + +def legal(): + return "451 Unavailable For Legal Reasons\n", 451 + +@app.errorhandler(400) +@app.errorhandler(404) +@app.errorhandler(414) +@app.errorhandler(415) +def segfault(e): + return "Segmentation fault\n", e.code + +@app.errorhandler(404) +def notfound(e): + return make_response("404 File not found: {0}/{1}".format(fhost_url(),request.path.lstrip("/")),404) + +@manager.command +def debug(): + app.config["FHOST_USE_X_ACCEL_REDIRECT"] = False + app.run(debug=True, port=4562,host="0.0.0.0") + +@manager.command +def permadelete(name): + id = su.debase(name) + f = File.query.get(id) + + if f: + if os.path.exists(getpath(f.sha256)): + os.remove(getpath(f.sha256)) + f.removed = True + db.session.commit() + +@manager.command +def query(name): + id = su.debase(name) + f = File.query.get(id) + + if f: + f.pprint() + +@manager.command +def queryhash(h): + f = File.query.filter_by(sha256=h).first() + + if f: + f.pprint() + +@manager.command +def queryaddr(a, nsfw=False, removed=False): + res = File.query.filter_by(addr=a) + + if not removed: + res = res.filter(File.removed != True) + + if nsfw: + res = res.filter(File.nsfw_score > app.config["NSFW_THRESHOLD"]) + + for f in res: + f.pprint() + +@manager.command +def deladdr(a): + res = File.query.filter_by(addr=a).filter(File.removed != True) + + for f in res: + if os.path.exists(getpath(f.sha256)): + os.remove(getpath(f.sha256)) + f.removed = True + + db.session.commit() + +def nsfw_detect(f): + try: + open(f["path"], 'r').close() + f["nsfw_score"] = nsfw.detect(f["path"]) + return f + except: + return None + +@manager.command +def update_nsfw(): + if not app.config["NSFW_DETECT"]: + print("NSFW detection is disabled in app config") + return 1 + + from multiprocessing import Pool + import tqdm + + res = File.query.filter_by(nsfw_score=None, removed=False) + + with Pool() as p: + results = [] + work = [{ "path" : getpath(f.sha256), "id" : f.id} for f in res] + + for r in tqdm.tqdm(p.imap_unordered(nsfw_detect, work), total=len(work)): + if r: + results.append({"id": r["id"], "nsfw_score" : r["nsfw_score"]}) + + db.session.bulk_update_mappings(File, results) + db.session.commit() + + +@manager.command +def querybl(nsfw=False, removed=False): + blist = [] + if os.path.isfile(app.config["FHOST_UPLOAD_BLACKLIST"]): + with open(app.config["FHOST_UPLOAD_BLACKLIST"], "r") as bl: + for l in bl.readlines(): + if not l.startswith("#"): + if not ":" in l: + blist.append("::ffff:" + l.rstrip()) + else: + blist.append(l.strip()) + + res = File.query.filter(File.addr.in_(blist)) + + if not removed: + res = res.filter(File.removed != True) + + if nsfw: + res = res.filter(File.nsfw_score > app.config["NSFW_THRESHOLD"]) + + for f in res: + f.pprint() + +if __name__ == "__main__": + # allow port adjustment + manager.add_command('runserver', Server(host=app.config["APP_HOST_LISTEN"], port=app.config["APP_PORT"])) + manager.run() |