gitme.py
Python script, ASCII text executable
1import os 2import flask 3from flask_sqlalchemy import SQLAlchemy 4import git 5import mimetypes 6import magic 7from flask_bcrypt import Bcrypt 8from markupsafe import escape, Markup 9from flask_migrate import Migrate 10 11import config 12 13app = flask.Flask(__name__) 14 15from flask_httpauth import HTTPBasicAuth 16auth = HTTPBasicAuth() 17 18app.config["SQLALCHEMY_DATABASE_URI"] = f"postgresql://root:{config.DB_PASSWORD}@localhost/gitme" 19app.config["SECRET_KEY"] = config.DB_PASSWORD 20app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 21db = SQLAlchemy(app) 22bcrypt = Bcrypt(app) 23migrate = Migrate(app, db) 24 25 26def onlyChars(string, chars): 27for i in string: 28if i not in chars: 29return False 30return True 31 32 33with app.app_context(): 34class User(db.Model): 35username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 36displayName = db.Column(db.String(128), unique=False, nullable=True) 37passwordHashed = db.Column(db.String(60), nullable=False) 38email = db.Column(db.String(254), nullable=True) 39 40def __init__(self, username, password, email=None, displayName=None): 41self.username = username 42self.passwordHashed = bcrypt.generate_password_hash(password, config.HASHING_ROUNDS).decode("utf-8") 43self.email = email 44 45 46import gitHTTP 47 48 49def humanSize(value, decimals=2, scale=1024, units=("B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB")): 50for unit in units: 51if value < scale: 52break 53value /= scale 54if int(value) == value: 55# do not return decimals, if the value is already round 56return int(value), unit 57return round(value * 10**decimals) / 10**decimals, unit 58 59 60def guessMIME(path): 61if os.path.isdir(path): 62mimetype = "inode/directory" 63elif magic.from_file(path, mime=True): 64mimetype = magic.from_file(path, mime=True) 65else: 66mimetype = "application/octet-stream" 67return mimetype 68 69 70def convertToHTML(path): 71with open(path, "r") as f: 72contents = f.read() 73return contents 74 75 76@app.context_processor 77def default(): 78username = flask.session.get("username") 79 80return {"loggedInUser": username} 81 82 83@app.route("/") 84def main(): 85return flask.render_template("home.html", title="gitme") 86 87 88@app.route("/settings/") 89def main(): 90return flask.render_template("user-settings.html", title="gitme") 91 92 93@app.route("/accounts/", methods=["GET", "POST"]) 94def login(): 95if flask.request.method == "GET": 96return flask.render_template("login.html", title="gitme") 97else: 98if "login" in flask.request.form: 99username = flask.request.form["username"] 100password = flask.request.form["password"] 101 102user = User.query.filter_by(username=username).first() 103 104if user and bcrypt.check_password_hash(user.passwordHashed, password): 105flask.session["username"] = user.username 106flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged in as {username}"), category="success") 107return flask.redirect("/", code=303) 108elif not user: 109flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>User not found"), category="alert") 110return flask.render_template("login.html", title="gitme") 111else: 112flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>Invalid password"), category="error") 113return flask.render_template("login.html", title="gitme") 114if "signup" in flask.request.form: 115username = flask.request.form["username"] 116password = flask.request.form["password"] 117password2 = flask.request.form["password2"] 118email = flask.request.form.get("email") 119email2 = flask.request.form.get("email2") # repeat email is a honeypot 120name = flask.request.form.get("name") 121 122if not onlyChars(username, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"): 123flask.flash(Markup("<iconify-icon icon='mdi:account-error'></iconify-icon>Usernames may only contain Latin alphabet, numbers, '-' and '_'"), category="error") 124return flask.render_template("login.html", title="gitme") 125 126if username in config.RESERVED_NAMES: 127flask.flash(Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>Sorry, {username} is a system path"), category="error") 128return flask.render_template("login.html", title="gitme") 129 130userCheck = User.query.filter_by(username=username).first() 131if userCheck: 132flask.flash(Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>The username {username} is taken"), category="error") 133return flask.render_template("login.html", title="gitme") 134 135if password2 != password: 136flask.flash(Markup("<iconify-icon icon='mdi:key-alert'></iconify-icon>Make sure the passwords match"), category="error") 137return flask.render_template("login.html", title="gitme") 138 139user = User(username, password, email, name) 140os.makedirs(os.path.join(config.REPOS_PATH, username)) 141db.session.add(user) 142db.session.commit() 143flask.session["username"] = user.username 144flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully created and logged in as {username}"), category="success") 145return flask.redirect("/", code=303) 146 147 148@app.route("/logout") 149def logout(): 150flask.session.clear() 151flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged out"), category="info") 152return flask.redirect("/", code=303) 153 154 155@app.route("/<username>/") 156def userProfile(username): 157return flask.render_template("teapot.html"), 418 158 159 160@app.route("/<username>/<repository>/") 161def repositoryIndex(username, repository): 162return flask.redirect("./tree", code=302) 163 164 165@app.route("/<username>/<repository>/raw/<branch>/<path:subpath>") 166def repositoryRaw(username, repository, branch, subpath): 167serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository)) 168 169app.logger.info(f"Loading {serverRepoLocation}") 170 171if not os.path.exists(serverRepoLocation): 172app.logger.error(f"Cannot load {serverRepoLocation}") 173return flask.render_template("not-found.html"), 404 174 175repo = git.Repo(serverRepoLocation) 176try: 177repo.git.checkout(branch) 178except git.exc.GitCommandError: 179return flask.render_template("not-found.html"), 404 180 181return flask.send_from_directory(config.REPOS_PATH, os.path.join(username, repository, subpath)) 182 183 184@app.route("/<username>/<repository>/tree/", defaults={"branch": None, "subpath": ""}) 185@app.route("/<username>/<repository>/tree/<branch>/", defaults={"subpath": ""}) 186@app.route("/<username>/<repository>/tree/<branch>/<path:subpath>") 187def repositoryTree(username, repository, branch, subpath): 188serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository)) 189 190app.logger.info(f"Loading {serverRepoLocation}") 191 192if not os.path.exists(serverRepoLocation): 193app.logger.error(f"Cannot load {serverRepoLocation}") 194return flask.render_template("not-found.html"), 404 195 196repo = git.Repo(serverRepoLocation) 197if not branch: 198branch = repo.heads[0].name 199return flask.redirect(f"./{branch}", code=302) 200try: 201repo.git.checkout(branch) 202except git.exc.GitCommandError: 203return flask.render_template("not-found.html"), 404 204 205branches = repo.heads 206if os.path.isdir(os.path.join(serverRepoLocation, subpath)): 207files = [] 208blobs = [] 209 210for entry in os.listdir(os.path.join(serverRepoLocation, subpath)): 211if not os.path.basename(entry) == ".git": 212files.append(os.path.join(subpath, entry)) 213 214infos = [] 215 216for file in files: 217path = os.path.join(serverRepoLocation, file) 218mimetype = guessMIME(path) 219 220info = { 221"name": os.path.basename(file), 222"serverPath": path, 223"relativePath": file, 224"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file), 225"size": humanSize(os.path.getsize(path)), 226"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}", 227} 228 229specialIcon = config.matchIcon(os.path.basename(file)) 230if specialIcon: 231info["icon"] = specialIcon 232elif os.path.isdir(path): 233info["icon"] = config.folderIcon 234elif mimetypes.guess_type(path)[0] in config.fileIcons: 235info["icon"] = config.fileIcons[mimetypes.guess_type(path)[0]] 236else: 237info["icon"] = config.unknownIcon 238 239if os.path.isdir(path): 240infos.insert(0, info) 241else: 242infos.append(info) 243 244return flask.render_template( 245"repo-tree.html", 246username=username, 247repository=repository, 248files=infos, 249subpath=os.path.join("/", subpath), 250branches=branches, 251current=branch 252) 253else: 254path = os.path.join(serverRepoLocation, subpath) 255 256if not os.path.exists(path): 257return flask.render_template("not-found.html"), 404 258 259mimetype = guessMIME(path) 260mode = mimetype.split("/", 1)[0] 261size = humanSize(os.path.getsize(path)) 262 263specialIcon = config.matchIcon(os.path.basename(path)) 264if specialIcon: 265icon = specialIcon 266elif os.path.isdir(path): 267icon = config.folderIcon 268elif guessMIME(path)[0] in config.fileIcons: 269icon = config.fileIcons[guessMIME(path)[0]] 270else: 271icon = config.unknownIcon 272 273contents = None 274if mode == "text": 275contents = convertToHTML(path) 276 277return flask.render_template( 278"repo-file.html", 279username=username, 280repository=repository, 281file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath), 282branches=branches, 283current=branch, 284mode=mode, 285mimetype=mimetype, 286detailedtype=magic.from_file(path), 287size=size, 288icon=icon, 289subpath=os.path.join("/", subpath), 290basename=os.path.basename(path), 291contents=contents 292) 293 294 295@app.route("/<username>/<repository>/forum/") 296def repositoryForum(username, repository): 297return flask.render_template("repo-forum.html", username=username, repository=repository) 298 299 300@app.route("/<username>/<repository>/docs/") 301def repositoryDocs(username, repository): 302return flask.render_template("repo-docs.html", username=username, repository=repository) 303 304 305@app.route("/<username>/<repository>/releases/") 306def repositoryReleases(username, repository): 307return flask.render_template("repo-releases.html", username=username, repository=repository) 308 309 310@app.route("/<username>/<repository>/branches/") 311def repositoryBranches(username, repository): 312return flask.render_template("repo-branches.html", username=username, repository=repository) 313 314 315@app.route("/<username>/<repository>/people/") 316def repositoryPeople(username, repository): 317return flask.render_template("repo-people.html", username=username, repository=repository) 318 319 320@app.route("/<username>/<repository>/activity/") 321def repositoryActivity(username, repository): 322return flask.render_template("repo-activity.html", username=username, repository=repository) 323 324 325@app.route("/<username>/<repository>/ci/") 326def repositoryCI(username, repository): 327return flask.render_template("repo-ci.html", username=username, repository=repository) 328 329 330@app.route("/<username>/<repository>/settings/") 331def repositorySettings(username, repository): 332flask.abort(401) 333return flask.render_template("repo-settings.html", username=username, repository=repository) 334 335 336@app.errorhandler(404) 337def e404(error): 338return flask.render_template("not-found.html"), 404 339 340 341@app.errorhandler(401) 342def e401(error): 343return flask.render_template("unauthorised.html"), 401 344 345 346@app.errorhandler(403) 347def e403(error): 348return flask.render_template("forbidden.html"), 403 349 350 351@app.errorhandler(418) 352def e418(error): 353return flask.render_template("teapot.html"), 418 354 355