app.py
Python script, Unicode text, UTF-8 text executable
1__version__ = "0.4.0" 2 3import os 4import shutil 5import random 6import subprocess 7import platform 8 9import PIL 10import git 11import mimetypes 12import magic 13import flask 14import cairosvg 15import celery 16import shlex 17from functools import wraps 18from datetime import datetime 19from enum import Enum 20from cairosvg import svg2png 21from flask_sqlalchemy import SQLAlchemy 22from flask_bcrypt import Bcrypt 23from markupsafe import escape, Markup 24from flask_migrate import Migrate 25from PIL import Image 26from flask_httpauth import HTTPBasicAuth 27import config 28import markdown 29from common import git_command 30from flask_babel import Babel, gettext, ngettext, force_locale 31from jinja2_fragments.flask import render_block 32 33import logging 34 35 36class No304(logging.Filter): 37def filter(self, record): 38return not record.getMessage().strip().endswith("304 -") 39 40 41logging.getLogger("werkzeug").addFilter(No304()) 42 43_ = gettext 44n_ = ngettext 45 46app = flask.Flask(__name__) 47app.config.from_mapping( 48CELERY=dict( 49broker_url=config.REDIS_URI, 50result_backend=config.REDIS_URI, 51task_ignore_result=True, 52), 53) 54 55auth = HTTPBasicAuth() 56 57app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 58app.config["SECRET_KEY"] = config.DB_PASSWORD 59app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 60app.config["BABEL_TRANSLATION_DIRECTORIES"] = "i18n" 61app.config["MAX_CONTENT_LENGTH"] = config.MAX_PAYLOAD_SIZE 62app.config["SESSION_COOKIE_SAMESITE"] = "Lax" 63app.config["SESSION_COOKIE_SECURE"] = config.suggest_https # only send cookies over HTTPS if the server is configured for it 64app.config["SESSION_COOKIE_HTTPONLY"] = True # don't allow JS to access the cookie 65if config.restrict_cookie_domain: 66app.config["SESSION_COOKIE_DOMAIN"] = config.BASE_DOMAIN # don't share across subdomains, since user content is hosted there 67 68db = SQLAlchemy(app) 69bcrypt = Bcrypt(app) 70migrate = Migrate(app, db) 71 72from misc_utils import * 73 74import git_http 75import api 76import jinja_utils 77import celery_tasks 78from celery import Celery, Task 79import celery_integration 80import pathlib 81 82from models import * 83 84babel = Babel(app) 85 86 87def get_locale(): 88if flask.request.cookies.get("language"): 89return flask.request.cookies.get("language") 90return flask.request.accept_languages.best_match(config.available_locales) 91 92 93babel.init_app(app, locale_selector=get_locale) 94 95with app.app_context(): 96locale_names = {} 97for language in config.available_locales: 98with force_locale(language): 99# NOTE: Translate this to the language's name in that language, for example in French you would use français 100locale_names[language] = gettext("English") 101 102worker = celery_integration.init_celery_app(app) 103 104repositories = flask.Blueprint("repository", __name__, template_folder="templates/repository/") 105 106app.jinja_env.add_extension("jinja2.ext.do") 107app.jinja_env.add_extension("jinja2.ext.loopcontrols") 108app.jinja_env.add_extension("jinja2.ext.debug") 109 110 111@app.context_processor 112def default(): 113username = flask.session.get("username") 114 115user_object = User.query.filter_by(username=username).first() 116 117return { 118"logged_in_user": username, 119"user_object": user_object, 120"Notification": Notification, 121"unread": UserNotification.query.filter_by(user_username=username).filter( 122UserNotification.attention_level > 0).count(), 123"config": config, 124"Markup": Markup, 125"locale_names": locale_names, 126"set": set, # since using {} is impossible in Jinja 127"request": flask.request, 128} 129 130 131@app.route("/") 132def main(): 133if flask.session.get("username"): 134return flask.render_template("home.html") 135else: 136return flask.render_template("no-home.html") 137 138 139@app.route("/userstyle") 140def userstyle(): 141if flask.session.get("username") and os.path.exists( 142os.path.join(config.REPOS_PATH, flask.session.get("username"), ".config", 143"theme.css")): 144return flask.send_from_directory( 145os.path.join(config.REPOS_PATH, flask.session.get("username"), ".config"), 146"theme.css") 147else: 148return flask.Response("", mimetype="text/css") 149 150 151@app.route("/about/") 152def about(): 153return flask.render_template("about.html", platform=platform, version=__version__) 154 155 156@app.route("/search") 157def search(): 158query = flask.request.args.get("q") 159page_number = flask.request.args.get("page", 1, type=int) 160if flask.session.get("username"): 161default_page_length = db.session.get(User, flask.session.get("username")).default_page_length 162else: 163default_page_length = 16 164page_length = flask.request.args.get("per_page", default_page_length, type=int) 165if not query: 166flask.abort(400) 167 168results = Repo.query.filter(Repo.name.ilike(f"%{query}%")).filter_by(visibility=2).paginate( 169page=page_number, per_page=page_length) 170 171if results.has_next: 172next_page = results.next_num 173else: 174next_page = None 175 176if results.has_prev: 177prev_page = results.prev_num 178else: 179prev_page = None 180 181return flask.render_template("search.html", results=results, query=query, 182page_number=page_number, 183page_length=page_length, 184next_page=next_page, 185prev_page=prev_page, 186num_pages=results.pages) 187 188 189@app.route("/language", methods=["POST"]) 190def set_locale(): 191response = flask.redirect(flask.request.referrer if flask.request.referrer else "/", 192code=303) 193if not flask.request.form.get("language"): 194response.delete_cookie("language") 195else: 196response.set_cookie("language", flask.request.form.get("language")) 197 198return response 199 200 201@app.route("/cookie-dismiss") 202def dismiss_banner(): 203response = flask.redirect(flask.request.referrer if flask.request.referrer else "/", 204code=303) 205response.set_cookie("cookie-banner", "1") 206return response 207 208 209@app.route("/help/") 210def help_redirect(): 211return flask.redirect(config.help_url, code=302) 212 213 214@app.route("/settings/") 215def settings(): 216if not flask.session.get("username"): 217flask.abort(401) 218user = User.query.filter_by(username=flask.session.get("username")).first() 219 220return flask.render_template("user-settings.html", user=user) 221 222 223@app.route("/settings/confirm-email/<code>") 224def confirm_email(code): 225request = EmailChangeRequest.query.filter_by(code=code).first() 226if not request: 227flask.abort(404) 228 229user = db.session.get(User, request.user_username) 230user.email = request.new_email 231db.session.delete(request) 232db.session.commit() 233 234return flask.redirect("/settings", code=303) 235 236 237@app.route("/settings/profile", methods=["POST"]) 238def settings_profile(): 239user = User.query.filter_by(username=flask.session.get("username")).first() 240 241user.display_name = flask.request.form["displayname"] 242user.URL = flask.request.form["url"] 243user.company = flask.request.form["company"] 244user.company_URL = flask.request.form["companyurl"] 245if not flask.request.form.get("email"): 246# Deleting the email can be instant; no need to confirm 247user.email = "" 248elif flask.request.form.get("email") != user.email: 249# Changing the email requires confirmation from the address holder 250celery_tasks.request_email_change.delay(user.username, flask.request.form["email"]) 251user.location = flask.request.form["location"] 252user.show_mail = True if flask.request.form.get("showmail") else False 253user.bio = flask.request.form.get("bio") 254 255db.session.commit() 256 257flask.flash( 258Markup("<iconify-icon icon='mdi:check'></iconify-icon>" + _("Settings saved")), 259category="success") 260return flask.redirect(f"/{flask.session.get('username')}", code=303) 261 262 263@app.route("/settings/preferences", methods=["POST"]) 264def settings_prefs(): 265user = User.query.filter_by(username=flask.session.get("username")).first() 266 267user.default_page_length = int(flask.request.form["page_length"]) 268user.max_post_nesting = int(flask.request.form["max_post_nesting"]) 269 270db.session.commit() 271 272flask.flash( 273Markup("<iconify-icon icon='mdi:check'></iconify-icon>" + _("Settings saved")), 274category="success") 275return flask.redirect(f"/{flask.session.get('username')}", code=303) 276 277 278@app.route("/favourites/", methods=["GET", "POST"]) 279def favourites(): 280if not flask.session.get("username"): 281flask.abort(401) 282if flask.request.method == "GET": 283relationships = RepoFavourite.query.filter_by( 284user_username=flask.session.get("username")) 285 286return flask.render_template("favourites.html", favourites=relationships) 287 288 289@app.route("/favourites/<int:id>", methods=["POST"]) 290def favourite_edit(id): 291if not flask.session.get("username"): 292flask.abort(401) 293favourite = db.session.get(RepoFavourite, id) 294if favourite.user_username != flask.session.get("username"): 295flask.abort(403) 296data = flask.request.form 297favourite.notify_commit = js_to_bool(data.get("commit")) 298favourite.notify_forum = js_to_bool(data.get("forum")) 299favourite.notify_pr = js_to_bool(data.get("pull_request")) 300favourite.notify_admin = js_to_bool(data.get("administrative")) 301db.session.commit() 302return flask.render_template_string( 303""" 304<tr hx-post="/favourites/{{ favourite.id }}" hx-trigger="change" hx-include="#commit-{{ favourite.id }}, #forum-{{ favourite.id }}, #pull_request-{{ favourite.id }}, #administrative-{{ favourite.id }}" hx-headers='{"Content-Type": "application/json"}' hx-swap="outerHTML"> 305<td><a href="{{ favourite.repo.route }}">{{ favourite.repo.owner.username }}/{{ favourite.repo.name }}</a></td> 306<td style="text-align: center;"><input type="checkbox" name="commit" id="commit-{{ favourite.id }}" value="true" {% if favourite.notify_commit %}checked{% endif %}></td> 307<td style="text-align: center;"><input type="checkbox" name="forum" id="forum-{{ favourite.id }}" value="true" {% if favourite.notify_forum %}checked{% endif %}></td> 308<td style="text-align: center;"><input type="checkbox" name="pull_request" id="pull_request-{{ favourite.id }}" value="true" {% if favourite.notify_pr %}checked{% endif %}></td> 309<td style="text-align: center;"><input type="checkbox" name="administrative" id="administrative-{{ favourite.id }}" value="true" {% if favourite.notify_admin %}checked{% endif %}></td> 310</tr> 311""", 312favourite=favourite 313) 314 315 316@app.route("/notifications/", methods=["GET", "POST"]) 317def notifications(): 318if not flask.session.get("username"): 319flask.abort(401) 320if flask.request.method == "GET": 321page_number = flask.request.args.get("page", 1, type=int) 322if flask.session.get("username"): 323default_page_length = db.session.get(User, flask.session.get( 324"username")).default_page_length 325else: 326default_page_length = 16 327page_length = flask.request.args.get("per_page", default_page_length, type=int) 328 329results = UserNotification.query.filter_by( 330user_username=flask.session.get("username")).order_by(UserNotification.id.desc()).paginate( 331page=page_number, per_page=page_length) 332 333if results.has_next: 334next_page = results.next_num 335else: 336next_page = None 337 338if results.has_prev: 339prev_page = results.prev_num 340else: 341prev_page = None 342 343return flask.render_template("notifications.html", 344notifications=results, 345db=db, Commit=Commit, Post=Post, PullRequest=PullRequest, 346page_number=page_number, 347page_length=page_length, 348next_page=next_page, 349prev_page=prev_page, 350num_pages=results.pages 351) 352 353 354@app.route("/notifications/<int:notification_id>/read", methods=["POST"]) 355def mark_read(notification_id): 356if not flask.session.get("username"): 357flask.abort(401) 358notification = UserNotification.query.filter_by(id=notification_id).first() 359if notification.user_username != flask.session.get("username"): 360flask.abort(403) 361notification.mark_read() 362db.session.commit() 363return flask.render_template_string( 364"<button hx-post='/notifications/{{ notification.id }}/unread' hx-swap='outerHTML'>Mark as unread</button>", 365notification=notification), 200 366 367 368@app.route("/notifications/<int:notification_id>/unread", methods=["POST"]) 369def mark_unread(notification_id): 370if not flask.session.get("username"): 371flask.abort(401) 372notification = UserNotification.query.filter_by(id=notification_id).first() 373if notification.user_username != flask.session.get("username"): 374flask.abort(403) 375notification.mark_unread() 376db.session.commit() 377return flask.render_template_string( 378"<button hx-post='/notifications/{{ notification.id }}/read' hx-swap='outerHTML'>Mark as read</button>", 379notification=notification), 200 380 381 382@app.route("/notifications/mark-all-read", methods=["POST"]) 383def mark_all_read(): 384if not flask.session.get("username"): 385flask.abort(401) 386 387notifications = UserNotification.query.filter_by( 388user_username=flask.session.get("username")) 389for notification in notifications: 390notification.mark_read() 391db.session.commit() 392return flask.redirect("/notifications/", code=303) 393 394 395@app.route("/accounts/", methods=["GET", "POST"]) 396def login(): 397if flask.request.method == "GET": 398return flask.render_template("login.html") 399else: 400if "login" in flask.request.form: 401username = flask.request.form["username"] 402password = flask.request.form["password"] 403 404user = User.query.filter_by(username=username).first() 405 406if user and bcrypt.check_password_hash(user.password_hashed, password): 407flask.session["username"] = user.username 408flask.flash( 409Markup("<iconify-icon icon='mdi:account'></iconify-icon>" + _( 410"Successfully logged in as {username}").format( 411username=username)), 412category="success") 413return flask.redirect("/", code=303) 414elif not user: 415flask.flash(Markup( 416"<iconify-icon icon='mdi:account-question'></iconify-icon>" + _( 417"User not found")), 418category="alert") 419return flask.render_template("login.html") 420else: 421flask.flash(Markup( 422"<iconify-icon icon='mdi:account-question'></iconify-icon>" + _( 423"Invalid password")), 424category="error") 425return flask.render_template("login.html") 426if "signup" in flask.request.form: 427username = flask.request.form["username"] 428password = flask.request.form["password"] 429password2 = flask.request.form["password2"] 430email = flask.request.form.get("email") 431email2 = flask.request.form.get("email2") # repeat email is a honeypot 432name = flask.request.form.get("name") 433 434if not only_chars(username, 435"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-"): 436flask.flash(Markup( 437_("Usernames may only contain Latin alphabet, numbers and '-'")), 438category="error") 439return flask.render_template("login.html") 440if "--" in username: 441flask.flash(Markup( 442_("Usernames may not contain consecutive hyphens")), 443category="error") 444return flask.render_template("login.html") 445if username.startswith("-") or username.endswith("-"): 446flask.flash(Markup( 447_("Usernames may not start or end with a hyphen")), 448category="error") 449return flask.render_template("login.html") 450if username in config.RESERVED_NAMES: 451flask.flash( 452Markup( 453_("Sorry, {username} is a system path").format( 454username=username)), 455category="error") 456return flask.render_template("login.html") 457 458if not username.islower(): 459if not name: # infer display name from the wanted username if not customised 460display_name = username 461username = username.lower() 462flask.flash(Markup( 463_("Usernames must be lowercase, so it's been converted automatically")), 464category="info") 465 466user_check = User.query.filter_by(username=username).first() 467if user_check or email2: # make the honeypot look like a normal error 468flask.flash( 469Markup( 470_( 471"The username {username} is taken").format( 472username=username)), 473category="error") 474return flask.render_template("login.html") 475 476if password2 != password: 477flask.flash(Markup(_( 478"Make sure the passwords match")), 479category="error") 480return flask.render_template("login.html") 481 482user = User(username, password, email, name) 483db.session.add(user) 484db.session.commit() 485flask.session["username"] = user.username 486flask.flash(Markup( 487_( 488"Successfully created and logged in as {username}").format( 489username=username)), 490category="success") 491 492notification = Notification({"type": "welcome"}) 493db.session.add(notification) 494db.session.commit() 495 496return flask.redirect("/", code=303) 497 498 499@app.route("/newrepo/", methods=["GET", "POST"]) 500def new_repo(): 501if not flask.session.get("username"): 502flask.abort(401) 503if flask.request.method == "GET": 504return flask.render_template("new-repo.html") 505else: 506name = flask.request.form["name"] 507visibility = int(flask.request.form["visibility"]) 508 509if not only_chars(name, 510"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_."): 511flask.flash(Markup( 512"<iconify-icon icon='mdi:error'></iconify-icon>" + _( 513"Repository names may only contain Latin alphabet, numbers, '-', '_' and '.'")), 514category="error") 515return flask.render_template("new-repo.html") 516 517user = User.query.filter_by(username=flask.session.get("username")).first() 518 519repo = Repo(user, name, visibility) 520db.session.add(repo) 521db.session.commit() 522 523flask.flash(Markup(_("Successfully created repository {name}").format(name=name)), 524category="success") 525return flask.redirect(repo.route, code=303) 526 527 528@app.route("/logout") 529def logout(): 530flask.session.clear() 531flask.flash(Markup( 532"<iconify-icon icon='mdi:account'></iconify-icon>" + _("Successfully logged out")), 533category="info") 534return flask.redirect("/", code=303) 535 536 537@app.route("/<username>/", methods=["GET", "POST"]) 538def user_profile(username): 539if db.session.get(User, username) is None: 540flask.abort(404) 541old_relationship = UserFollow.query.filter_by( 542follower_username=flask.session.get("username"), 543followed_username=username).first() 544if flask.request.method == "GET": 545user = User.query.filter_by(username=username).first() 546match flask.request.args.get("action"): 547case "repositories": 548repos = Repo.query.filter_by(owner_name=username, visibility=2) 549return flask.render_template("user-profile-repositories.html", user=user, 550repos=repos, 551relationship=old_relationship) 552case "followers": 553return flask.render_template("user-profile-followers.html", user=user, 554relationship=old_relationship) 555case "follows": 556return flask.render_template("user-profile-follows.html", user=user, 557relationship=old_relationship) 558case _: 559return flask.render_template("user-profile-overview.html", user=user, 560relationship=old_relationship) 561 562elif flask.request.method == "POST": 563match flask.request.args.get("action"): 564case "follow": 565if username == flask.session.get("username"): 566flask.abort(403) 567if old_relationship: 568db.session.delete(old_relationship) 569else: 570relationship = UserFollow( 571flask.session.get("username"), 572username 573) 574db.session.add(relationship) 575db.session.commit() 576 577user = db.session.get(User, username) 578author = db.session.get(User, flask.session.get("username")) 579notification = Notification({"type": "update", "version": "0.0.0"}) 580db.session.add(notification) 581db.session.commit() 582 583db.session.commit() 584return flask.redirect("?", code=303) 585 586 587@app.route("/<username>/<repository>/") 588def repository_index(username, repository): 589return flask.redirect("./tree", code=302) 590 591 592@app.route("/info/<username>/avatar") 593def user_avatar(username): 594server_userdata_location = os.path.join(config.USERDATA_PATH, username) 595if not os.path.exists(server_userdata_location): 596return flask.render_template("errors/not-found.html"), 404 597 598return flask.send_from_directory(server_userdata_location, "avatar.png") 599 600 601@app.route("/info/<username>/avatar", methods=["POST"]) 602def user_avatar_upload(username): 603server_userdata_location = os.path.join(config.USERDATA_PATH, username) 604 605if not os.path.exists(server_userdata_location): 606flask.abort(404) 607if not flask.session.get("username") == username: 608flask.abort(403) 609 610# Convert image to PNG 611try: 612image = Image.open(flask.request.files["avatar"]) 613except PIL.UnidentifiedImageError: 614flask.abort(400) 615image.save(os.path.join(server_userdata_location, "avatar.png")) 616 617return flask.redirect(f"/{username}", code=303) 618 619 620@app.route("/<username>/<repository>/raw/<branch>/<path:subpath>") 621def repository_raw(username, repository, branch, subpath): 622server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 623if not os.path.exists(server_repo_location): 624flask.abort(404) 625if not (get_visibility(username, repository) or get_permission_level( 626flask.session.get("username"), username, 627repository) is not None): 628flask.abort(403) 629 630if not os.path.exists(server_repo_location): 631return flask.render_template("errors/not-found.html"), 404 632 633repo = git.Repo(server_repo_location) 634repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 635if not repo_data.default_branch: 636if repo.heads: 637repo_data.default_branch = repo.heads[0].name 638else: 639return flask.render_template("empty.html", 640remote=f"http://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200 641if not branch: 642branch = repo_data.default_branch 643return flask.redirect(f"./{branch}", code=302) 644 645if branch.startswith("tag:"): 646ref = f"tags/{branch[4:]}" 647elif branch.startswith("~"): 648ref = branch[1:] 649else: 650ref = f"heads/{branch}" 651 652ref = ref.replace("~", "/") # encode slashes for URL support 653 654try: 655repo.git.checkout("-f", ref) 656except git.exc.GitCommandError: 657return flask.render_template("errors/not-found.html"), 404 658 659return flask.send_from_directory(config.REPOS_PATH, 660os.path.join(username, repository, subpath)) 661 662 663@repositories.route("/<username>/<repository>/tree/", defaults={"branch": None, "subpath": ""}) 664@repositories.route("/<username>/<repository>/tree/<branch>/", defaults={"subpath": ""}) 665@repositories.route("/<username>/<repository>/tree/<branch>/<path:subpath>") 666def repository_tree(username, repository, branch, subpath): 667server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 668if not os.path.exists(server_repo_location): 669flask.abort(404) 670if not (get_visibility(username, repository) or get_permission_level( 671flask.session.get("username"), username, 672repository) is not None): 673flask.abort(403) 674 675repo = git.Repo(server_repo_location) 676repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 677if not repo_data.default_branch: 678if repo.heads: 679repo_data.default_branch = repo.heads[0].name 680else: 681return flask.render_template("empty.html", 682remote=f"{config.www_protocol}://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200 683if not branch: 684branch = repo_data.default_branch 685return flask.redirect(f"./{branch}", code=302) 686 687if branch.startswith("tag:"): 688ref = f"tags/{branch[4:]}" 689elif branch.startswith("~"): 690ref = branch[1:] 691else: 692ref = f"heads/{branch}" 693 694ref = ref.replace("~", "/") # encode slashes for URL support 695 696try: 697repo.git.checkout("-f", ref) 698except git.exc.GitCommandError: 699return flask.render_template("errors/not-found.html"), 404 700 701branches = repo.heads 702 703all_refs = [] 704for ref in repo.heads: 705all_refs.append((ref, "head")) 706for ref in repo.tags: 707all_refs.append((ref, "tag")) 708 709if os.path.isdir(os.path.join(server_repo_location, subpath)): 710files = [] 711blobs = [] 712 713for entry in os.listdir(os.path.join(server_repo_location, subpath)): 714if not os.path.basename(entry) == ".git": 715files.append(os.path.join(subpath, entry)) 716 717infos = [] 718 719for file in files: 720path = os.path.join(server_repo_location, file) 721mimetype = guess_mime(path) 722 723text = git_command(server_repo_location, None, "log", "--format='%H\n'", 724shlex.quote(file)).decode() 725 726sha = text.split("\n")[0] 727identifier = f"/{username}/{repository}/{sha}" 728 729last_commit = db.session.get(Commit, identifier) 730 731info = { 732"name": os.path.basename(file), 733"serverPath": path, 734"relativePath": file, 735"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file), 736"size": human_size(os.path.getsize(path)), 737"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}", 738"commit": last_commit, 739"shaSize": 7, 740} 741 742special_icon = config.match_icon(os.path.basename(file)) 743if special_icon: 744info["icon"] = special_icon 745elif os.path.isdir(path): 746info["icon"] = config.folder_icon 747info["size"] = _("{} files").format(len(os.listdir(path))) 748elif mimetypes.guess_type(path)[0] in config.file_icons: 749info["icon"] = config.file_icons[mimetypes.guess_type(path)[0]] 750else: 751info["icon"] = config.unknown_icon 752 753if os.path.isdir(path): 754infos.insert(0, info) 755else: 756infos.append(info) 757 758return flask.render_template( 759"repo-tree.html", 760username=username, 761repository=repository, 762files=infos, 763subpath=os.path.join("/", subpath), 764branches=all_refs, 765current=branch, 766remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 767is_favourite=get_favourite(flask.session.get("username"), username, repository), 768repo_data=repo_data, 769) 770else: 771path = os.path.join(server_repo_location, subpath) 772 773if not os.path.exists(path): 774return flask.render_template("errors/not-found.html"), 404 775 776mimetype = guess_mime(path) 777mode = mimetype.split("/", 1)[0] 778size = human_size(os.path.getsize(path)) 779 780special_icon = config.match_icon(os.path.basename(path)) 781if special_icon: 782icon = special_icon 783elif os.path.isdir(path): 784icon = config.folder_icon 785elif mimetypes.guess_type(path)[0] in config.file_icons: 786icon = config.file_icons[mimetypes.guess_type(path)[0]] 787else: 788icon = config.unknown_icon 789 790contents = None 791if mode == "text": 792contents = convert_to_html(path) 793 794return flask.render_template( 795"repo-file.html", 796username=username, 797repository=repository, 798file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath), 799branches=all_refs, 800current=branch, 801mode=mode, 802mimetype=mimetype, 803detailedtype=magic.from_file(path), 804size=size, 805icon=icon, 806subpath=os.path.join("/", subpath), 807extension=pathlib.Path(path).suffix, 808basename=os.path.basename(path), 809contents=contents, 810remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 811is_favourite=get_favourite(flask.session.get("username"), username, repository), 812repo_data=repo_data, 813) 814 815 816@repositories.route("/<username>/<repository>/commit/<sha>") 817def repository_commit(username, repository, sha): 818server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 819if not os.path.exists(server_repo_location): 820flask.abort(404) 821if not (get_visibility(username, repository) or get_permission_level( 822flask.session.get("username"), username, 823repository) is not None): 824flask.abort(403) 825 826if not os.path.exists(server_repo_location): 827return flask.render_template("errors/not-found.html"), 404 828 829repo = git.Repo(server_repo_location) 830repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 831 832files = git_command(os.path.join(server_repo_location, ".git"), None, "diff-tree", "-r", 833"--name-only", "--no-commit-id", sha).decode().split("\n")[:-1] 834 835return flask.render_template( 836"repo-commit.html", 837username=username, 838repository=repository, 839remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 840is_favourite=get_favourite(flask.session.get("username"), username, repository), 841diff={file: git_command(os.path.join(server_repo_location, ".git"), None, "diff", 842str(sha) + "^!", "--", file).decode().split("\n") for 843file in files}, 844data=db.session.get(Commit, f"/{username}/{repository}/{sha}"), 845repo_data=repo_data, 846comment_query=Comment.query, 847permission_level=get_permission_level(flask.session.get("username"), username, repository), 848) 849 850 851@repositories.route("/<username>/<repository>/commit/<sha>/add_comment", methods=["POST"]) 852def repository_commit_add_comment(username, repository, sha): 853server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 854if not os.path.exists(server_repo_location): 855flask.abort(404) 856if not (get_visibility(username, repository) or get_permission_level( 857flask.session.get("username"), username, 858repository) is not None): 859flask.abort(403) 860 861comment = Comment( 862db.session.get(User, flask.session.get("username")), 863db.session.get(Repo, f"/{username}/{repository}"), 864db.session.get(Commit, f"/{username}/{repository}/{sha}"), 865flask.request.form["comment"], 866flask.request.form["file"], 867flask.request.form["line"], 868) 869 870db.session.add(comment) 871db.session.commit() 872 873return flask.redirect( 874flask.url_for(".repository_commit", username=username, repository=repository, sha=sha), 875code=303 876) 877 878 879@repositories.route("/<username>/<repository>/commit/<sha>/delete_comment/<int:id>", methods=["POST"]) 880def repository_commit_delete_comment(username, repository, sha, id): 881repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 882comment = Comment.query.filter_by(identifier=f"/{username}/{repository}/{id}").first() 883commit = Commit.query.filter_by(identifier=f"/{username}/{repository}/{sha}").first() 884if ( 885comment.owner.username == flask.session.get("username") 886or get_permission_level(flask.session.get("username"), username, repository) >= 2 887or comment.commit.owner.username == flask.session.get("username") 888): 889db.session.delete(comment) 890db.session.commit() 891 892return flask.redirect( 893flask.url_for(".repository_commit", username=username, repository=repository, sha=sha), 894code=303 895) 896 897 898@repositories.route("/<username>/<repository>/commit/<sha>/resolve_comment/<int:id>", methods=["POST"]) 899def repository_commit_resolve_comment(username, repository, sha, id): 900comment = Comment.query.filter_by(identifier=f"/{username}/{repository}/{id}").first() 901if ( 902comment.commit.owner.username == flask.session.get("username") 903or get_permission_level(flask.session.get("username"), username, repository) >= 2 904or comment.owner.username == flask.session.get("username") 905): 906comment.state = int(not comment.state) 907db.session.commit() 908 909return flask.redirect( 910flask.url_for(".repository_commit", username=username, repository=repository, sha=sha), 911code=303 912) 913 914 915@repositories.route("/<username>/<repository>/forum/") 916def repository_forum(username, repository): 917server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 918if not os.path.exists(server_repo_location): 919flask.abort(404) 920if not (get_visibility(username, repository) or get_permission_level( 921flask.session.get("username"), username, 922repository) is not None): 923flask.abort(403) 924 925if not os.path.exists(server_repo_location): 926return flask.render_template("errors/not-found.html"), 404 927 928repo = git.Repo(server_repo_location) 929repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 930user = User.query.filter_by(username=flask.session.get("username")).first() 931relationships = RepoAccess.query.filter_by(repo=repo_data) 932user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 933 934return flask.render_template( 935"repo-forum.html", 936username=username, 937repository=repository, 938repo_data=repo_data, 939relationships=relationships, 940repo=repo, 941user_relationship=user_relationship, 942Post=Post, 943remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 944is_favourite=get_favourite(flask.session.get("username"), username, repository), 945default_branch=repo_data.default_branch 946) 947 948 949@repositories.route("/<username>/<repository>/forum/topic/<int:id>") 950def repository_forum_topic(username, repository, id): 951server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 952if not os.path.exists(server_repo_location): 953flask.abort(404) 954if not (get_visibility(username, repository) or get_permission_level( 955flask.session.get("username"), username, 956repository) is not None): 957flask.abort(403) 958 959if not os.path.exists(server_repo_location): 960return flask.render_template("errors/not-found.html"), 404 961 962repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 963user = User.query.filter_by(username=flask.session.get("username")).first() 964relationships = RepoAccess.query.filter_by(repo=repo_data) 965user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 966 967post = Post.query.filter_by(id=id).first() 968 969return flask.render_template( 970"repo-topic.html", 971username=username, 972repository=repository, 973repo_data=repo_data, 974relationships=relationships, 975user_relationship=user_relationship, 976post=post, 977remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 978is_favourite=get_favourite(flask.session.get("username"), username, repository), 979default_branch=repo_data.default_branch 980) 981 982 983@repositories.route("/<username>/<repository>/forum/new", methods=["POST", "GET"]) 984def repository_forum_new(username, repository): 985server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 986if not os.path.exists(server_repo_location): 987flask.abort(404) 988if not ((flask.session.get("username") and get_visibility(username, repository)) or get_permission_level( 989flask.session.get("username"), username, 990repository) is not None): 991flask.abort(403) 992 993if not os.path.exists(server_repo_location): 994return flask.render_template("errors/not-found.html"), 404 995 996repo = git.Repo(server_repo_location) 997repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 998user = User.query.filter_by(username=flask.session.get("username")).first() 999relationships = RepoAccess.query.filter_by(repo=repo_data) 1000user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1001 1002post = Post(user, repo_data, None, flask.request.form["subject"], 1003flask.request.form["message"]) 1004 1005db.session.add(post) 1006db.session.commit() 1007 1008return flask.redirect( 1009flask.url_for(".repository_forum_thread", username=username, repository=repository, 1010post_id=post.number), 1011code=303) 1012 1013 1014@repositories.route("/<username>/<repository>/forum/<int:post_id>") 1015def repository_forum_thread(username, repository, post_id): 1016server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1017if not os.path.exists(server_repo_location): 1018flask.abort(404) 1019if not (get_visibility(username, repository) or get_permission_level( 1020flask.session.get("username"), username, 1021repository) is not None): 1022flask.abort(403) 1023 1024if not os.path.exists(server_repo_location): 1025return flask.render_template("errors/not-found.html"), 404 1026 1027repo = git.Repo(server_repo_location) 1028repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1029user = User.query.filter_by(username=flask.session.get("username")).first() 1030relationships = RepoAccess.query.filter_by(repo=repo_data) 1031user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1032 1033if user: 1034max_post_nesting = user.max_post_nesting 1035else: 1036max_post_nesting = 2 1037 1038return flask.render_template( 1039"repo-forum-thread.html", 1040username=username, 1041repository=repository, 1042repo_data=repo_data, 1043relationships=relationships, 1044repo=repo, 1045Post=Post, 1046user_relationship=user_relationship, 1047post_id=post_id, 1048max_post_nesting=max_post_nesting, 1049remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1050is_favourite=get_favourite(flask.session.get("username"), username, repository), 1051parent=Post.query.filter_by(repo=repo_data, number=post_id).first(), 1052has_permission=not ((not get_permission_level(flask.session.get("username"), username, 1053repository)) and db.session.get(Post, 1054f"/{username}/{repository}/{post_id}").owner.username != flask.session.get("username")), 1055) 1056 1057 1058@repositories.route("/<username>/<repository>/forum/<int:post_id>/change-state", 1059methods=["POST"]) 1060def repository_forum_change_state(username, repository, post_id): 1061server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1062if not os.path.exists(server_repo_location): 1063flask.abort(404) 1064if (not get_permission_level(flask.session.get("username"), username, repository)) and db.session.get(Post, f"/{username}/{repository}/{post_id}").owner.username != flask.session.get("username"): 1065flask.abort(403) 1066 1067repo = git.Repo(server_repo_location) 1068repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1069user = User.query.filter_by(username=flask.session.get("username")).first() 1070relationships = RepoAccess.query.filter_by(repo=repo_data) 1071user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1072 1073post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1074 1075if not post: 1076flask.abort(404) 1077 1078post.state = int(flask.request.form["new-state"]) 1079 1080db.session.commit() 1081 1082return flask.redirect( 1083flask.url_for(".repository_forum_thread", username=username, repository=repository, 1084post_id=post_id), 1085code=303) 1086 1087 1088@repositories.route("/<username>/<repository>/forum/<int:post_id>/reply", methods=["POST"]) 1089def repository_forum_reply(username, repository, post_id): 1090server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1091if not os.path.exists(server_repo_location): 1092flask.abort(404) 1093if not ((flask.session.get("username") and get_visibility(username, repository)) or get_permission_level( 1094flask.session.get("username"), username, 1095repository) is not None): 1096flask.abort(403) 1097 1098if not os.path.exists(server_repo_location): 1099return flask.render_template("errors/not-found.html"), 404 1100 1101repo = git.Repo(server_repo_location) 1102repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1103user = User.query.filter_by(username=flask.session.get("username")).first() 1104relationships = RepoAccess.query.filter_by(repo=repo_data) 1105user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1106if not user: 1107flask.abort(401) 1108 1109parent = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1110post = Post(user, repo_data, parent, flask.request.form["subject"], 1111flask.request.form["message"]) 1112 1113db.session.add(post) 1114post.update_date() 1115db.session.commit() 1116 1117return flask.redirect( 1118flask.url_for(".repository_forum_thread", username=username, repository=repository, 1119post_id=post_id), 1120code=303) 1121 1122 1123@repositories.route("/<username>/<repository>/forum/<int:post_id>/edit", methods=["POST"]) 1124def repository_forum_edit(username, repository, post_id): 1125server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1126if not os.path.exists(server_repo_location): 1127flask.abort(404) 1128if not (get_visibility(username, repository) or get_permission_level( 1129flask.session.get("username"), username, 1130repository) is not None): 1131flask.abort(403) 1132 1133if not os.path.exists(server_repo_location): 1134return flask.render_template("errors/not-found.html"), 404 1135 1136repo = git.Repo(server_repo_location) 1137repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1138user = User.query.filter_by(username=flask.session.get("username")).first() 1139relationships = RepoAccess.query.filter_by(repo=repo_data) 1140user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1141if not user: 1142flask.abort(401) 1143post = db.session.get(Post, f"/{username}/{repository}/{post_id}") 1144if user != post.owner: 1145flask.abort(403) 1146 1147post.subject = flask.request.form["subject"] 1148post.message = flask.request.form["message"] 1149post.html = markdown.markdown2html(post.message).prettify() 1150post.update_date() 1151db.session.commit() 1152 1153return flask.redirect( 1154flask.url_for(".repository_forum_thread", username=username, repository=repository, 1155post_id=post_id), 1156code=303) 1157 1158 1159@repositories.route("/<username>/<repository>/forum/<int:post_id>/edit", methods=["GET"]) 1160def repository_forum_edit_form(username, repository, post_id): 1161server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1162if not os.path.exists(server_repo_location): 1163flask.abort(404) 1164if not (get_visibility(username, repository) or get_permission_level( 1165flask.session.get("username"), username, 1166repository) is not None): 1167flask.abort(403) 1168 1169if not os.path.exists(server_repo_location): 1170return flask.render_template("errors/not-found.html"), 404 1171 1172repo = git.Repo(server_repo_location) 1173repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1174user = User.query.filter_by(username=flask.session.get("username")).first() 1175relationships = RepoAccess.query.filter_by(repo=repo_data) 1176user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1177if not user: 1178flask.abort(401) 1179post = db.session.get(Post, f"/{username}/{repository}/{post_id}") 1180if user != post.owner: 1181flask.abort(403) 1182 1183return flask.render_template( 1184"repo-forum-edit.html", 1185username=username, 1186repository=repository, 1187repo_data=repo_data, 1188relationships=relationships, 1189repo=repo, 1190user_relationship=user_relationship, 1191post=post, 1192remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1193is_favourite=get_favourite(flask.session.get("username"), username, repository), 1194default_branch=repo_data.default_branch 1195) 1196 1197@repositories.route("/<username>/<repository>/forum/<int:post_id>/voteup", 1198defaults={"score": 1}) 1199@repositories.route("/<username>/<repository>/forum/<int:post_id>/votedown", 1200defaults={"score": -1}) 1201@repositories.route("/<username>/<repository>/forum/<int:post_id>/votes", defaults={"score": 0}) 1202def repository_forum_vote(username, repository, post_id, score): 1203server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1204if not os.path.exists(server_repo_location): 1205flask.abort(404) 1206if not (get_visibility(username, repository) or get_permission_level( 1207flask.session.get("username"), username, 1208repository) is not None): 1209flask.abort(403) 1210 1211if not os.path.exists(server_repo_location): 1212return flask.render_template("errors/not-found.html"), 404 1213 1214repo = git.Repo(server_repo_location) 1215repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1216user = User.query.filter_by(username=flask.session.get("username")).first() 1217relationships = RepoAccess.query.filter_by(repo=repo_data) 1218user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1219if not user: 1220flask.abort(401) 1221 1222post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1223 1224if score: 1225old_relationship = PostVote.query.filter_by(user_username=user.username, 1226post_identifier=post.identifier).first() 1227if old_relationship: 1228if score == old_relationship.vote_score: 1229db.session.delete(old_relationship) 1230post.vote_sum -= old_relationship.vote_score 1231else: 1232post.vote_sum -= old_relationship.vote_score 1233post.vote_sum += score 1234old_relationship.vote_score = score 1235else: 1236relationship = PostVote(user, post, score) 1237post.vote_sum += score 1238db.session.add(relationship) 1239 1240db.session.commit() 1241 1242user_vote = PostVote.query.filter_by(user_username=user.username, 1243post_identifier=post.identifier).first() 1244response = flask.make_response( 1245str(post.vote_sum) + " " + str(user_vote.vote_score if user_vote else 0)) 1246response.content_type = "text/plain" 1247 1248return response 1249 1250 1251@repositories.route("/<username>/<repository>/favourite") 1252def repository_favourite(username, repository): 1253server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1254if not os.path.exists(server_repo_location): 1255flask.abort(404) 1256if not (get_visibility(username, repository) or get_permission_level( 1257flask.session.get("username"), username, 1258repository) is not None): 1259flask.abort(403) 1260 1261if not os.path.exists(server_repo_location): 1262return flask.render_template("errors/not-found.html"), 404 1263 1264repo = git.Repo(server_repo_location) 1265repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1266user = User.query.filter_by(username=flask.session.get("username")).first() 1267relationships = RepoAccess.query.filter_by(repo=repo_data) 1268user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1269if not user: 1270flask.abort(401) 1271 1272old_relationship = RepoFavourite.query.filter_by(user_username=user.username, 1273repo_route=repo_data.route).first() 1274if old_relationship: 1275db.session.delete(old_relationship) 1276else: 1277relationship = RepoFavourite(user, repo_data) 1278db.session.add(relationship) 1279 1280db.session.commit() 1281 1282return flask.redirect(flask.url_for("favourites"), code=303) 1283 1284 1285@repositories.route("/<username>/<repository>/users/", methods=["GET", "POST"]) 1286def repository_users(username, repository): 1287server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1288if not os.path.exists(server_repo_location): 1289flask.abort(404) 1290if not (get_visibility(username, repository) or get_permission_level( 1291flask.session.get("username"), username, 1292repository) is not None): 1293flask.abort(403) 1294 1295if not os.path.exists(server_repo_location): 1296return flask.render_template("errors/not-found.html"), 404 1297 1298repo = git.Repo(server_repo_location) 1299repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1300user = User.query.filter_by(username=flask.session.get("username")).first() 1301relationships = RepoAccess.query.filter_by(repo=repo_data) 1302user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1303 1304if flask.request.method == "GET": 1305return flask.render_template( 1306"repo-users.html", 1307username=username, 1308repository=repository, 1309repo_data=repo_data, 1310relationships=relationships, 1311repo=repo, 1312user_relationship=user_relationship, 1313remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1314is_favourite=get_favourite(flask.session.get("username"), username, repository) 1315) 1316else: 1317if get_permission_level(flask.session.get("username"), username, repository) != 2: 1318flask.abort(401) 1319 1320if flask.request.form.get("new-username"): 1321# Create new relationship 1322new_user = User.query.filter_by( 1323username=flask.request.form.get("new-username")).first() 1324relationship = RepoAccess(new_user, repo_data, flask.request.form.get("new-level")) 1325db.session.add(relationship) 1326db.session.commit() 1327if flask.request.form.get("update-username"): 1328# Create new relationship 1329updated_user = User.query.filter_by( 1330username=flask.request.form.get("update-username")).first() 1331relationship = RepoAccess.query.filter_by(repo=repo_data, user=updated_user).first() 1332if flask.request.form.get("update-level") == -1: 1333relationship.delete() 1334else: 1335relationship.access_level = flask.request.form.get("update-level") 1336db.session.commit() 1337 1338return flask.redirect( 1339app.url_for(".repository_users", username=username, repository=repository)) 1340 1341 1342@repositories.route("/<username>/<repository>/branches/") 1343def repository_branches(username, repository): 1344server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1345if not os.path.exists(server_repo_location): 1346flask.abort(404) 1347if not (get_visibility(username, repository) or get_permission_level( 1348flask.session.get("username"), username, 1349repository) is not None): 1350flask.abort(403) 1351 1352if not os.path.exists(server_repo_location): 1353return flask.render_template("errors/not-found.html"), 404 1354 1355repo = git.Repo(server_repo_location) 1356repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1357 1358return flask.render_template( 1359"repo-branches.html", 1360username=username, 1361repository=repository, 1362repo_data=repo_data, 1363repo=repo, 1364remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1365is_favourite=get_favourite(flask.session.get("username"), username, repository) 1366) 1367 1368 1369@repositories.route("/<username>/<repository>/log/", defaults={"branch": None}) 1370@repositories.route("/<username>/<repository>/log/<branch>/") 1371def repository_log(username, repository, branch): 1372server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1373if not os.path.exists(server_repo_location): 1374flask.abort(404) 1375if not (get_visibility(username, repository) or get_permission_level( 1376flask.session.get("username"), username, 1377repository) is not None): 1378flask.abort(403) 1379 1380if not os.path.exists(server_repo_location): 1381return flask.render_template("errors/not-found.html"), 404 1382 1383repo = git.Repo(server_repo_location) 1384repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1385if not repo_data.default_branch: 1386if repo.heads: 1387repo_data.default_branch = repo.heads[0].name 1388else: 1389return flask.render_template("empty.html", 1390remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200 1391if not branch: 1392branch = repo_data.default_branch 1393return flask.redirect(f"./{branch}", code=302) 1394 1395if branch.startswith("tag:"): 1396ref = f"tags/{branch[4:]}" 1397elif branch.startswith("~"): 1398ref = branch[1:] 1399else: 1400ref = f"heads/{branch}" 1401 1402ref = ref.replace("~", "/") # encode slashes for URL support 1403 1404try: 1405repo.git.checkout("-f", ref) 1406except git.exc.GitCommandError: 1407return flask.render_template("errors/not-found.html"), 404 1408 1409branches = repo.heads 1410 1411all_refs = [] 1412for ref in repo.heads: 1413all_refs.append((ref, "head")) 1414for ref in repo.tags: 1415all_refs.append((ref, "tag")) 1416 1417commit_list = [f"/{username}/{repository}/{sha}" for sha in 1418git_command(server_repo_location, None, "log", 1419"--format='%H'").decode().split("\n")] 1420 1421commits = Commit.query.filter(Commit.identifier.in_(commit_list)).order_by(Commit.author_date.desc()) 1422page_number = flask.request.args.get("page", 1, type=int) 1423if flask.session.get("username"): 1424default_page_length = db.session.get(User, flask.session.get("username")).default_page_length 1425else: 1426default_page_length = 16 1427page_length = flask.request.args.get("per_page", default_page_length, type=int) 1428page_listing = db.paginate(commits, page=page_number, per_page=page_length) 1429 1430if page_listing.has_next: 1431next_page = page_listing.next_num 1432else: 1433next_page = None 1434 1435if page_listing.has_prev: 1436prev_page = page_listing.prev_num 1437else: 1438prev_page = None 1439 1440return flask.render_template( 1441"repo-log.html", 1442username=username, 1443repository=repository, 1444branches=all_refs, 1445current=branch, 1446repo_data=repo_data, 1447repo=repo, 1448commits=page_listing, 1449remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1450is_favourite=get_favourite(flask.session.get("username"), username, repository), 1451page_number=page_number, 1452page_length=page_length, 1453next_page=next_page, 1454prev_page=prev_page, 1455num_pages=page_listing.pages 1456) 1457 1458 1459@repositories.route("/<username>/<repository>/prs/", methods=["GET", "POST"]) 1460def repository_prs(username, repository): 1461server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1462if not os.path.exists(server_repo_location): 1463flask.abort(404) 1464if not (get_visibility(username, repository) or get_permission_level( 1465flask.session.get("username"), username, 1466repository) is not None): 1467flask.abort(403) 1468 1469if not os.path.exists(server_repo_location): 1470return flask.render_template("errors/not-found.html"), 404 1471 1472if flask.request.method == "GET": 1473repo = git.Repo(server_repo_location) 1474repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1475user = User.query.filter_by(username=flask.session.get("username")).first() 1476 1477return flask.render_template( 1478"repo-prs.html", 1479username=username, 1480repository=repository, 1481repo_data=repo_data, 1482repo=repo, 1483PullRequest=PullRequest, 1484remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1485is_favourite=get_favourite(flask.session.get("username"), username, repository), 1486default_branch=repo_data.default_branch, 1487branches=repo.branches 1488) 1489 1490else: 1491repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1492head = flask.request.form.get("head") 1493head_route = flask.request.form.get("headroute") 1494base = flask.request.form.get("base") 1495 1496if not head and base and head_route: 1497return flask.redirect(".", 400) 1498 1499head_repo = git.Repo(os.path.join(config.REPOS_PATH, head_route.lstrip("/"))) 1500base_repo = git.Repo(server_repo_location) 1501 1502if head not in head_repo.branches or base not in base_repo.branches: 1503flask.flash(Markup( 1504"<iconify-icon icon='mdi:error'></iconify-icon>" + _("Bad branch name")), 1505category="error") 1506return flask.redirect(".", 303) 1507 1508head_data = db.session.get(Repo, head_route) 1509if not head_data.visibility: 1510flask.flash(Markup( 1511"<iconify-icon icon='mdi:error'></iconify-icon>" + _( 1512"Head can't be restricted")), 1513category="error") 1514return flask.redirect(".", 303) 1515 1516pull_request = PullRequest(head_data, head, repo_data, base, 1517db.session.get(User, flask.session["username"])) 1518 1519db.session.add(pull_request) 1520db.session.commit() 1521 1522# Create the notification 1523notification = Notification({"type": "pr", "head": pull_request.head.route, "base": pull_request.base.route, "pr": pull_request.id}) 1524db.session.add(notification) 1525db.session.commit() 1526 1527# Send a notification to all users who have enabled PR notifications for this repo 1528for relationship in RepoFavourite.query.filter_by(repo_route=pull_request.base.route, notify_pr=True).all(): 1529user = relationship.user 1530user_notification = UserNotification(user, notification, 1) 1531db.session.add(user_notification) 1532db.session.commit() 1533celery_tasks.send_notification.apply_async(args=[user_notification.id]) 1534 1535return flask.redirect(".", 303) 1536 1537 1538@repositories.route("/<username>/<repository>/prs/merge", methods=["POST"]) 1539def repository_prs_merge(username, repository): 1540server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1541if not os.path.exists(server_repo_location): 1542flask.abort(404) 1543if not (get_visibility(username, repository) or get_permission_level( 1544flask.session.get("username"), username, 1545repository) is not None): 1546flask.abort(403) 1547 1548if not get_permission_level(flask.session.get("username"), username, repository): 1549flask.abort(401) 1550 1551repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1552repo = git.Repo(server_repo_location) 1553id = flask.request.form.get("id") 1554 1555pull_request = db.session.get(PullRequest, id) 1556 1557if pull_request: 1558result = celery_tasks.merge_heads.delay( 1559pull_request.head_route, 1560pull_request.head_branch, 1561pull_request.base_route, 1562pull_request.base_branch, 1563simulate=True 1564) 1565task_result = worker.AsyncResult(result.id) 1566 1567return flask.redirect(f"/task/{result.id}?pr-id={id}", 303) # should be 202 Accepted but we must use a redirect 1568# db.session.delete(pull_request) 1569# db.session.commit() 1570else: 1571flask.abort(400) 1572 1573 1574@repositories.route("/<username>/<repository>/prs/<int:id>/merge") 1575def repository_prs_merge_stage_two(username, repository, id): 1576server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1577if not os.path.exists(server_repo_location): 1578flask.abort(404) 1579if not (get_visibility(username, repository) or get_permission_level( 1580flask.session.get("username"), username, 1581repository) is not None): 1582flask.abort(403) 1583 1584if not get_permission_level(flask.session.get("username"), username, repository): 1585flask.abort(401) 1586 1587repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1588repo = git.Repo(server_repo_location) 1589 1590pull_request = db.session.get(PullRequest, id) 1591 1592if pull_request: 1593result = celery_tasks.merge_heads.delay( 1594pull_request.head_route, 1595pull_request.head_branch, 1596pull_request.base_route, 1597pull_request.base_branch, 1598simulate=False 1599) 1600task_result = worker.AsyncResult(result.id) 1601 1602pull_request.state = 1 1603db.session.commit() 1604 1605return flask.redirect(f"/task/{result.id}?pr-id={id}", 303) 1606# db.session.delete(pull_request) 1607else: 1608flask.abort(400) 1609 1610 1611@app.route("/task/<task_id>") 1612def task_monitor(task_id): 1613task_result = worker.AsyncResult(task_id) 1614 1615if flask.request.args.get("partial"): 1616# htmx partial update 1617return render_block("task-monitor.html", "content", result=task_result, query_string=flask.request.query_string.decode(), delay=1000) 1618 1619# Since most tasks finish rather quickly, the initial delay is faster, so it doesn't wait for too long 1620return flask.render_template("task-monitor.html", result=task_result, query_string=flask.request.query_string.decode(), delay=125) 1621 1622 1623@repositories.route("/<username>/<repository>/prs/delete", methods=["POST"]) 1624def repository_prs_delete(username, repository): 1625server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1626if not os.path.exists(server_repo_location): 1627flask.abort(404) 1628if not (get_visibility(username, repository) or get_permission_level( 1629flask.session.get("username"), username, 1630repository) is not None): 1631flask.abort(403) 1632 1633if not get_permission_level(flask.session.get("username"), username, repository): 1634flask.abort(401) 1635 1636repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1637repo = git.Repo(server_repo_location) 1638id = flask.request.form.get("id") 1639 1640pull_request = db.session.get(PullRequest, id) 1641 1642if pull_request: 1643pull_request.state = 2 1644db.session.commit() 1645 1646return flask.redirect(".", 303) 1647 1648 1649@repositories.route("/<username>/<repository>/settings/") 1650def repository_settings(username, repository): 1651if get_permission_level(flask.session.get("username"), username, repository) != 2: 1652flask.abort(401) 1653 1654repo = git.Repo(os.path.join(config.REPOS_PATH, username, repository)) 1655 1656site_link = Markup(f"<code>http{'s' if config.suggest_https else ''}://{username}.{config.BASE_DOMAIN}/{repository}</code>") 1657primary_site_link = Markup(f"<code>http{'s' if config.suggest_https else ''}://{username}.{config.BASE_DOMAIN}/</code>") 1658 1659return flask.render_template("repo-settings.html", username=username, repository=repository, 1660repo_data=db.session.get(Repo, f"/{username}/{repository}"), 1661branches=[branch.name for branch in repo.branches], 1662site_link=site_link, primary_site_link=primary_site_link, 1663remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1664is_favourite=get_favourite(flask.session.get("username"), username, repository), 1665) 1666 1667 1668@repositories.route("/<username>/<repository>/settings/", methods=["POST"]) 1669def repository_settings_post(username, repository): 1670if get_permission_level(flask.session.get("username"), username, repository) != 2: 1671flask.abort(401) 1672 1673repo = db.session.get(Repo, f"/{username}/{repository}") 1674 1675repo.visibility = flask.request.form.get("visibility", type=int) 1676repo.info = flask.request.form.get("description") 1677repo.default_branch = flask.request.form.get("default_branch") 1678repo.url = flask.request.form.get("url") 1679 1680# Update site settings 1681had_site = repo.has_site 1682old_branch = repo.site_branch 1683if flask.request.form.get("site_branch"): 1684repo.site_branch = flask.request.form.get("site_branch") 1685if flask.request.form.get("primary_site"): 1686if had_site != 2: 1687# Remove primary site from other repos 1688for other_repo in Repo.query.filter_by(owner=repo.owner, has_site=2): 1689other_repo.has_site = 1 # switch it to a regular site 1690flask.flash(Markup( 1691_("Your repository {repository} has been demoted from a primary site to a regular site because there can only be one primary site per user.").format( 1692repository=other_repo.route 1693)), category="warning") 1694repo.has_site = 2 1695else: 1696repo.has_site = 1 1697else: 1698repo.site_branch = None 1699repo.has_site = 0 1700 1701db.session.commit() 1702 1703if not (had_site, old_branch) == (repo.has_site, repo.site_branch): 1704# Deploy the newly activated site 1705result = celery_tasks.copy_site.delay(repo.route) 1706 1707if had_site and not repo.has_site: 1708# Remove the site 1709result = celery_tasks.delete_site.delay(repo.route) 1710 1711if repo.has_site == 2 or (had_site == 2 and had_site != repo.has_site): 1712# Deploy all other sites which were destroyed by the primary site 1713for other_repo in Repo.query.filter_by(owner=repo.owner, has_site=1): 1714result = celery_tasks.copy_site.delay(other_repo.route) 1715 1716return flask.redirect(f"/{username}/{repository}/settings", 303) 1717 1718 1719@repositories.route("/<username>/<repository>/settings/delete", methods=["POST"]) 1720def repository_settings_delete(username, repository): 1721if username != flask.session.get("username"): 1722flask.abort(401) 1723 1724repo = db.session.get(Repo, f"/{username}/{repository}") 1725 1726if not repo: 1727flask.abort(404) 1728 1729user = db.session.get(User, flask.session.get("username")) 1730 1731if not bcrypt.check_password_hash(user.password_hashed, flask.request.form.get("password")): 1732flask.flash(_("Incorrect password"), category="error") 1733flask.abort(401) 1734 1735if repo.has_site: 1736celery_tasks.delete_site.delay(repo.route) 1737 1738db.session.delete(repo) 1739db.session.commit() 1740 1741shutil.rmtree(os.path.join(config.REPOS_PATH, username, repository)) 1742 1743return flask.redirect(f"/{username}", 303) 1744 1745 1746@app.errorhandler(404) 1747def e404(error): 1748return flask.render_template("errors/not-found.html"), 404 1749 1750 1751@app.errorhandler(401) 1752def e401(error): 1753return flask.render_template("errors/unauthorised.html"), 401 1754 1755 1756@app.errorhandler(403) 1757def e403(error): 1758return flask.render_template("errors/forbidden.html"), 403 1759 1760 1761@app.errorhandler(418) 1762def e418(error): 1763return flask.render_template("errors/teapot.html"), 418 1764 1765 1766@app.errorhandler(405) 1767def e405(error): 1768return flask.render_template("errors/method-not-allowed.html"), 405 1769 1770 1771@app.errorhandler(500) 1772def e500(error): 1773return flask.render_template("errors/server-error.html"), 500 1774 1775 1776@app.errorhandler(400) 1777def e400(error): 1778return flask.render_template("errors/bad-request.html"), 400 1779 1780 1781@app.errorhandler(410) 1782def e410(error): 1783return flask.render_template("errors/gone.html"), 410 1784 1785 1786@app.errorhandler(415) 1787def e415(error): 1788return flask.render_template("errors/media-type.html"), 415 1789 1790 1791if __name__ == "__main__": 1792app.run(debug=True, port=8080, host="0.0.0.0") 1793 1794app.register_blueprint(repositories) 1795