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