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="master") 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): 361serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository)) 362 363app.logger.info(f"Loading {serverRepoLocation}") 364 365if not os.path.exists(serverRepoLocation): 366app.logger.error(f"Cannot load {serverRepoLocation}") 367return flask.render_template("not-found.html"), 404 368 369repo = git.Repo(serverRepoLocation) 370try: 371repo.git.checkout(branch) 372except git.exc.GitCommandError: 373return flask.render_template("not-found.html"), 404 374 375return flask.send_from_directory(config.REPOS_PATH, os.path.join(username, repository, subpath)) 376 377 378@app.route("/info/<username>/avatar") 379def userAvatar(username): 380serverUserdataLocation = os.path.join(config.USERDATA_PATH, username) 381 382if not os.path.exists(serverUserdataLocation): 383return flask.render_template("not-found.html"), 404 384 385return flask.send_from_directory(serverUserdataLocation, "avatar.png") 386 387 388@app.route("/<username>/<repository>/tree/", defaults={"branch": None, "subpath": ""}) 389@app.route("/<username>/<repository>/tree/<branch>/", defaults={"subpath": ""}) 390@app.route("/<username>/<repository>/tree/<branch>/<path:subpath>") 391def repositoryTree(username, repository, branch, subpath): 392serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository)) 393 394app.logger.info(f"Loading {serverRepoLocation}") 395 396if not os.path.exists(serverRepoLocation): 397app.logger.error(f"Cannot load {serverRepoLocation}") 398return flask.render_template("not-found.html"), 404 399 400repo = git.Repo(serverRepoLocation) 401repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first() 402if not repoData.defaultBranch: 403return flask.render_template("empty.html", remote=f"http://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200 404else: 405if not branch: 406branch = repoData.defaultBranch 407return flask.redirect(f"./{branch}", code=302) 408else: 409try: 410repo.git.checkout("-f", branch) 411except git.exc.GitCommandError: 412return flask.render_template("not-found.html"), 404 413 414branches = repo.heads 415 416if os.path.isdir(os.path.join(serverRepoLocation, subpath)): 417files = [] 418blobs = [] 419 420for entry in os.listdir(os.path.join(serverRepoLocation, subpath)): 421if not os.path.basename(entry) == ".git": 422files.append(os.path.join(subpath, entry)) 423 424infos = [] 425 426for file in files: 427path = os.path.join(serverRepoLocation, file) 428mimetype = guessMIME(path) 429 430text = gitCommand(serverRepoLocation, None, "log", "--format='%H\n'", file).decode() 431 432sha = text.split("\n")[0] 433identifier = f"/{username}/{repository}/{sha}" 434lastCommit = Commit.query.filter_by(identifier=identifier).first() 435 436info = { 437"name": os.path.basename(file), 438"serverPath": path, 439"relativePath": file, 440"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file), 441"size": humanSize(os.path.getsize(path)), 442"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}", 443"commit": lastCommit, 444"shaSize": 7, 445} 446 447specialIcon = config.matchIcon(os.path.basename(file)) 448if specialIcon: 449info["icon"] = specialIcon 450elif os.path.isdir(path): 451info["icon"] = config.folderIcon 452elif mimetypes.guess_type(path)[0] in config.fileIcons: 453info["icon"] = config.fileIcons[mimetypes.guess_type(path)[0]] 454else: 455info["icon"] = config.unknownIcon 456 457if os.path.isdir(path): 458infos.insert(0, info) 459else: 460infos.append(info) 461 462return flask.render_template( 463"repo-tree.html", 464username=username, 465repository=repository, 466files=infos, 467subpath=os.path.join("/", subpath), 468branches=branches, 469current=branch 470) 471else: 472path = os.path.join(serverRepoLocation, subpath) 473 474if not os.path.exists(path): 475return flask.render_template("not-found.html"), 404 476 477mimetype = guessMIME(path) 478mode = mimetype.split("/", 1)[0] 479size = humanSize(os.path.getsize(path)) 480 481specialIcon = config.matchIcon(os.path.basename(path)) 482if specialIcon: 483icon = specialIcon 484elif os.path.isdir(path): 485icon = config.folderIcon 486elif guessMIME(path)[0] in config.fileIcons: 487icon = config.fileIcons[guessMIME(path)[0]] 488else: 489icon = config.unknownIcon 490 491contents = None 492if mode == "text": 493contents = convertToHTML(path) 494 495return flask.render_template( 496"repo-file.html", 497username=username, 498repository=repository, 499file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath), 500branches=branches, 501current=branch, 502mode=mode, 503mimetype=mimetype, 504detailedtype=magic.from_file(path), 505size=size, 506icon=icon, 507subpath=os.path.join("/", subpath), 508basename=os.path.basename(path), 509contents=contents 510) 511 512 513@app.route("/<username>/<repository>/forum/") 514def repositoryForum(username, repository): 515return flask.render_template("repo-forum.html", username=username, repository=repository) 516 517 518@app.route("/<username>/<repository>/docs/") 519def repositoryDocs(username, repository): 520return flask.render_template("repo-docs.html", username=username, repository=repository) 521 522 523@app.route("/<username>/<repository>/releases/") 524def repositoryReleases(username, repository): 525return flask.render_template("repo-releases.html", username=username, repository=repository) 526 527 528@app.route("/<username>/<repository>/branches/") 529def repositoryBranches(username, repository): 530return flask.render_template("repo-branches.html", username=username, repository=repository) 531 532 533@app.route("/<username>/<repository>/people/") 534def repositoryPeople(username, repository): 535return flask.render_template("repo-people.html", username=username, repository=repository) 536 537 538@app.route("/<username>/<repository>/activity/") 539def repositoryActivity(username, repository): 540return flask.render_template("repo-activity.html", username=username, repository=repository) 541 542 543@app.route("/<username>/<repository>/ci/") 544def repositoryCI(username, repository): 545return flask.render_template("repo-ci.html", username=username, repository=repository) 546 547 548@app.route("/<username>/<repository>/settings/") 549def repositorySettings(username, repository): 550flask.abort(401) 551return flask.render_template("repo-settings.html", username=username, repository=repository) 552 553 554@app.errorhandler(404) 555def e404(error): 556return flask.render_template("not-found.html"), 404 557 558 559@app.errorhandler(401) 560def e401(error): 561return flask.render_template("unauthorised.html"), 401 562 563 564@app.errorhandler(403) 565def e403(error): 566return flask.render_template("forbidden.html"), 403 567 568 569@app.errorhandler(418) 570def e418(error): 571return flask.render_template("teapot.html"), 418 572 573