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