app.py
Python script, ASCII text executable
1import os 2import random 3import subprocess 4 5import cairosvg 6import flask 7from flask_sqlalchemy import SQLAlchemy 8import git 9import mimetypes 10import magic 11from flask_bcrypt import Bcrypt 12from markupsafe import escape, Markup 13from flask_migrate import Migrate 14from datetime import datetime 15from enum import Enum 16import shutil 17from PIL import Image 18from cairosvg import svg2png 19 20import config 21 22app = flask.Flask(__name__) 23 24from flask_httpauth import HTTPBasicAuth 25auth = HTTPBasicAuth() 26 27app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 28app.config["SECRET_KEY"] = config.DB_PASSWORD 29app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 30db = SQLAlchemy(app) 31bcrypt = Bcrypt(app) 32migrate = Migrate(app, db) 33 34 35def onlyChars(string, chars): 36for i in string: 37if i not in chars: 38return False 39return True 40 41 42with app.app_context(): 43class User(db.Model): 44username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 45displayName = db.Column(db.Unicode(128), unique=False, nullable=True) 46bio = db.Column(db.Unicode(512), unique=False, nullable=True) 47passwordHashed = db.Column(db.String(60), nullable=False) 48email = db.Column(db.String(254), nullable=True) 49company = db.Column(db.Unicode(64), nullable=True) 50companyURL = db.Column(db.String(256), nullable=True) 51URL = db.Column(db.String(256), nullable=True) 52showMail = db.Column(db.Boolean, default=False, nullable=False) 53location = db.Column(db.Unicode(64), nullable=True) 54creationDate = db.Column(db.DateTime, default=datetime.utcnow) 55 56repositories = db.relationship("Repo", backref="owner") 57 58def __init__(self, username, password, email=None, displayName=None): 59self.username = username 60self.passwordHashed = bcrypt.generate_password_hash(password, config.HASHING_ROUNDS).decode("utf-8") 61self.email = email 62self.displayName = displayName 63 64# Create the user's directory 65if not os.path.exists(os.path.join(config.REPOS_PATH, username)): 66os.makedirs(os.path.join(config.REPOS_PATH, username)) 67if not os.path.exists(os.path.join(config.USERDATA_PATH, username)): 68os.makedirs(os.path.join(config.USERDATA_PATH, username)) 69 70avatarName = random.choice(os.listdir(config.DEFAULT_AVATARS_PATH)) 71if os.path.join(config.DEFAULT_AVATARS_PATH, avatarName).endswith(".svg"): 72cairosvg.svg2png(url=os.path.join(config.DEFAULT_AVATARS_PATH, avatarName), write_to="/tmp/gitme-avatar.png") 73avatar = Image.open("/tmp/roundabout-avatar.png") 74else: 75avatar = Image.open(os.path.join(config.DEFAULT_AVATARS_PATH, avatarName)) 76avatar.thumbnail(config.AVATAR_SIZE) 77avatar.save(os.path.join(config.USERDATA_PATH, username, "avatar.png")) 78 79 80class Repo(db.Model): 81route = db.Column(db.String(98), unique=True, nullable=False, primary_key=True) 82ownerName = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 83name = db.Column(db.String(64), nullable=False) 84# owner = db.relationship("User", back_populates="repositories") 85visibility = db.Column(db.SmallInteger(), nullable=False) 86info = db.Column(db.Unicode(512), nullable=True) 87URL = db.Column(db.String(256), nullable=True) 88creationDate = db.Column(db.DateTime, default=datetime.utcnow) 89 90defaultBranch = db.Column(db.String(64), nullable=True, default="master") 91 92commits = db.relationship("Commit", backref="repo") 93 94def __init__(self, owner, name, visibility): 95self.route = f"/{owner.username}/{name}" 96self.name = name 97self.ownerName = owner.username 98self.owner = owner 99self.visibility = visibility 100 101 102class Commit(db.Model): 103identifier = db.Column(db.String(227), unique=True, nullable=False, primary_key=True) 104sha = db.Column(db.String(128), nullable=False) 105repoName = db.Column(db.String(97), db.ForeignKey("repo.route"), nullable=False) 106ownerName = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 107ownerIdentity = db.Column(db.String(321)) 108receiveDate = db.Column(db.DateTime, default=datetime.utcnow) 109authorDate = db.Column(db.DateTime) 110message = db.Column(db.UnicodeText) 111 112def __init__(self, sha, owner, repo, date, message, ownerIdentity): 113self.sha = sha 114self.repoName = repo.route 115self.repo = repo 116self.ownerName = owner.username 117self.owner = owner 118self.authorDate = db.Column(db.DateTime) 119self.ownerIdentity = ownerIdentity 120 121import gitHTTP 122 123 124def humanSize(value, decimals=2, scale=1024, units=("B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB")): 125for unit in units: 126if value < scale: 127break 128value /= scale 129if int(value) == value: 130# do not return decimals, if the value is already round 131return int(value), unit 132return round(value * 10**decimals) / 10**decimals, unit 133 134 135def guessMIME(path): 136if os.path.isdir(path): 137mimetype = "inode/directory" 138elif magic.from_file(path, mime=True): 139mimetype = magic.from_file(path, mime=True) 140else: 141mimetype = "application/octet-stream" 142return mimetype 143 144 145def convertToHTML(path): 146with open(path, "r") as f: 147contents = f.read() 148return contents 149 150 151@app.context_processor 152def default(): 153username = flask.session.get("username") 154 155return {"loggedInUser": username} 156 157 158@app.route("/") 159def main(): 160return flask.render_template("home.html") 161 162 163@app.route("/settings/") 164def settings(): 165if not flask.session.get("username"): 166flask.abort(401) 167user = User.query.filter_by(username=flask.session.get("username")).first() 168 169return flask.render_template("user-settings.html", user=user) 170 171 172@app.route("/accounts/", methods=["GET", "POST"]) 173def login(): 174if flask.request.method == "GET": 175return flask.render_template("login.html") 176else: 177if "login" in flask.request.form: 178username = flask.request.form["username"] 179password = flask.request.form["password"] 180 181user = User.query.filter_by(username=username).first() 182 183if user and bcrypt.check_password_hash(user.passwordHashed, password): 184flask.session["username"] = user.username 185flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged in as {username}"), category="success") 186return flask.redirect("/", code=303) 187elif not user: 188flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>User not found"), category="alert") 189return flask.render_template("login.html") 190else: 191flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>Invalid password"), category="error") 192return flask.render_template("login.html") 193if "signup" in flask.request.form: 194username = flask.request.form["username"] 195password = flask.request.form["password"] 196password2 = flask.request.form["password2"] 197email = flask.request.form.get("email") 198email2 = flask.request.form.get("email2") # repeat email is a honeypot 199name = flask.request.form.get("name") 200 201if not onlyChars(username, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"): 202flask.flash(Markup("<iconify-icon icon='mdi:account-error'></iconify-icon>Usernames may only contain Latin alphabet, numbers, '-' and '_'"), category="error") 203return flask.render_template("login.html") 204 205if username in config.RESERVED_NAMES: 206flask.flash(Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>Sorry, {username} is a system path"), category="error") 207return flask.render_template("login.html") 208 209userCheck = User.query.filter_by(username=username).first() 210if userCheck: 211flask.flash(Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>The username {username} is taken"), category="error") 212return flask.render_template("login.html") 213 214if password2 != password: 215flask.flash(Markup("<iconify-icon icon='mdi:key-alert'></iconify-icon>Make sure the passwords match"), category="error") 216return flask.render_template("login.html") 217 218user = User(username, password, email, name) 219db.session.add(user) 220db.session.commit() 221flask.session["username"] = user.username 222flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully created and logged in as {username}"), category="success") 223return flask.redirect("/", code=303) 224 225 226@app.route("/newrepo/", methods=["GET", "POST"]) 227def newRepo(): 228if flask.request.method == "GET": 229return flask.render_template("new-repo.html") 230else: 231name = flask.request.form["name"] 232visibility = int(flask.request.form["visibility"]) 233 234if not onlyChars(name, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"): 235flask.flash(Markup( 236"<iconify-icon icon='mdi:error'></iconify-icon>Repository names may only contain Latin alphabet, numbers, '-' and '_'"), 237category="error") 238return flask.render_template("new-repo.html") 239 240user = User.query.filter_by(username=flask.session.get("username")).first() 241 242repo = Repo(user, name, visibility) 243db.session.add(repo) 244db.session.commit() 245 246if not os.path.exists(os.path.join(config.REPOS_PATH, repo.route)): 247subprocess.run(["git", "init", repo.name], cwd=os.path.join(config.REPOS_PATH, flask.session.get("username"))) 248 249flask.flash(Markup(f"<iconify-icon icon='mdi:folder'></iconify-icon>Successfully created repository {name}"), category="success") 250return flask.redirect(repo.route, code=303) 251 252 253@app.route("/logout") 254def logout(): 255flask.session.clear() 256flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged out"), category="info") 257return flask.redirect("/", code=303) 258 259 260@app.route("/<username>/") 261def userProfile(username): 262user = User.query.filter_by(username=username).first() 263repos = Repo.query.filter_by(ownerName=username, visibility=2) 264return flask.render_template("user-profile.html", user=user, repos=repos) 265 266 267@app.route("/<username>/<repository>/") 268def repositoryIndex(username, repository): 269return flask.redirect("./tree", code=302) 270 271 272@app.route("/<username>/<repository>/raw/<branch>/<path:subpath>") 273def repositoryRaw(username, repository, branch, subpath): 274serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository)) 275 276app.logger.info(f"Loading {serverRepoLocation}") 277 278if not os.path.exists(serverRepoLocation): 279app.logger.error(f"Cannot load {serverRepoLocation}") 280return flask.render_template("not-found.html"), 404 281 282repo = git.Repo(serverRepoLocation) 283try: 284repo.git.checkout(branch) 285except git.exc.GitCommandError: 286return flask.render_template("not-found.html"), 404 287 288return flask.send_from_directory(config.REPOS_PATH, os.path.join(username, repository, subpath)) 289 290 291@app.route("/info/<username>/avatar") 292def userAvatar(username): 293serverUserdataLocation = os.path.join(config.USERDATA_PATH, username) 294 295if not os.path.exists(serverUserdataLocation): 296return flask.render_template("not-found.html"), 404 297 298return flask.send_from_directory(serverUserdataLocation, "avatar.png") 299 300 301@app.route("/<username>/<repository>/tree/", defaults={"branch": None, "subpath": ""}) 302@app.route("/<username>/<repository>/tree/<branch>/", defaults={"subpath": ""}) 303@app.route("/<username>/<repository>/tree/<branch>/<path:subpath>") 304def repositoryTree(username, repository, branch, subpath): 305serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository)) 306 307app.logger.info(f"Loading {serverRepoLocation}") 308 309if not os.path.exists(serverRepoLocation): 310app.logger.error(f"Cannot load {serverRepoLocation}") 311return flask.render_template("not-found.html"), 404 312 313repo = git.Repo(serverRepoLocation) 314repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first() 315if not repoData.defaultBranch: 316return flask.render_template("empty.html", remote=f"http://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200 317else: 318if not branch: 319branch = repoData.defaultBranch 320return flask.redirect(f"./{branch}", code=302) 321else: 322try: 323repo.git.checkout("-f", branch) 324except git.exc.GitCommandError: 325return flask.render_template("not-found.html"), 404 326 327branches = repo.heads 328if os.path.isdir(os.path.join(serverRepoLocation, subpath)): 329files = [] 330blobs = [] 331 332for entry in os.listdir(os.path.join(serverRepoLocation, subpath)): 333if not os.path.basename(entry) == ".git": 334files.append(os.path.join(subpath, entry)) 335 336infos = [] 337 338for file in files: 339path = os.path.join(serverRepoLocation, file) 340mimetype = guessMIME(path) 341 342info = { 343"name": os.path.basename(file), 344"serverPath": path, 345"relativePath": file, 346"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file), 347"size": humanSize(os.path.getsize(path)), 348"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}", 349} 350 351specialIcon = config.matchIcon(os.path.basename(file)) 352if specialIcon: 353info["icon"] = specialIcon 354elif os.path.isdir(path): 355info["icon"] = config.folderIcon 356elif mimetypes.guess_type(path)[0] in config.fileIcons: 357info["icon"] = config.fileIcons[mimetypes.guess_type(path)[0]] 358else: 359info["icon"] = config.unknownIcon 360 361if os.path.isdir(path): 362infos.insert(0, info) 363else: 364infos.append(info) 365 366return flask.render_template( 367"repo-tree.html", 368username=username, 369repository=repository, 370files=infos, 371subpath=os.path.join("/", subpath), 372branches=branches, 373current=branch 374) 375else: 376path = os.path.join(serverRepoLocation, subpath) 377 378if not os.path.exists(path): 379return flask.render_template("not-found.html"), 404 380 381mimetype = guessMIME(path) 382mode = mimetype.split("/", 1)[0] 383size = humanSize(os.path.getsize(path)) 384 385specialIcon = config.matchIcon(os.path.basename(path)) 386if specialIcon: 387icon = specialIcon 388elif os.path.isdir(path): 389icon = config.folderIcon 390elif guessMIME(path)[0] in config.fileIcons: 391icon = config.fileIcons[guessMIME(path)[0]] 392else: 393icon = config.unknownIcon 394 395contents = None 396if mode == "text": 397contents = convertToHTML(path) 398 399return flask.render_template( 400"repo-file.html", 401username=username, 402repository=repository, 403file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath), 404branches=branches, 405current=branch, 406mode=mode, 407mimetype=mimetype, 408detailedtype=magic.from_file(path), 409size=size, 410icon=icon, 411subpath=os.path.join("/", subpath), 412basename=os.path.basename(path), 413contents=contents 414) 415 416 417@app.route("/<username>/<repository>/forum/") 418def repositoryForum(username, repository): 419return flask.render_template("repo-forum.html", username=username, repository=repository) 420 421 422@app.route("/<username>/<repository>/docs/") 423def repositoryDocs(username, repository): 424return flask.render_template("repo-docs.html", username=username, repository=repository) 425 426 427@app.route("/<username>/<repository>/releases/") 428def repositoryReleases(username, repository): 429return flask.render_template("repo-releases.html", username=username, repository=repository) 430 431 432@app.route("/<username>/<repository>/branches/") 433def repositoryBranches(username, repository): 434return flask.render_template("repo-branches.html", username=username, repository=repository) 435 436 437@app.route("/<username>/<repository>/people/") 438def repositoryPeople(username, repository): 439return flask.render_template("repo-people.html", username=username, repository=repository) 440 441 442@app.route("/<username>/<repository>/activity/") 443def repositoryActivity(username, repository): 444return flask.render_template("repo-activity.html", username=username, repository=repository) 445 446 447@app.route("/<username>/<repository>/ci/") 448def repositoryCI(username, repository): 449return flask.render_template("repo-ci.html", username=username, repository=repository) 450 451 452@app.route("/<username>/<repository>/settings/") 453def repositorySettings(username, repository): 454flask.abort(401) 455return flask.render_template("repo-settings.html", username=username, repository=repository) 456 457 458@app.errorhandler(404) 459def e404(error): 460return flask.render_template("not-found.html"), 404 461 462 463@app.errorhandler(401) 464def e401(error): 465return flask.render_template("unauthorised.html"), 401 466 467 468@app.errorhandler(403) 469def e403(error): 470return flask.render_template("forbidden.html"), 403 471 472 473@app.errorhandler(418) 474def e418(error): 475return flask.render_template("teapot.html"), 418 476 477