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