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