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 747elif mimetypes.guess_type(path)[0] in config.file_icons: 748info["icon"] = config.file_icons[mimetypes.guess_type(path)[0]] 749else: 750info["icon"] = config.unknown_icon 751 752if os.path.isdir(path): 753infos.insert(0, info) 754else: 755infos.append(info) 756 757return flask.render_template( 758"repo-tree.html", 759username=username, 760repository=repository, 761files=infos, 762subpath=os.path.join("/", subpath), 763branches=all_refs, 764current=branch, 765remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 766is_favourite=get_favourite(flask.session.get("username"), username, repository), 767repo_data=repo_data, 768) 769else: 770path = os.path.join(server_repo_location, subpath) 771 772if not os.path.exists(path): 773return flask.render_template("errors/not-found.html"), 404 774 775mimetype = guess_mime(path) 776mode = mimetype.split("/", 1)[0] 777size = human_size(os.path.getsize(path)) 778 779special_icon = config.match_icon(os.path.basename(path)) 780if special_icon: 781icon = special_icon 782elif os.path.isdir(path): 783icon = config.folder_icon 784elif mimetypes.guess_type(path)[0] in config.file_icons: 785icon = config.file_icons[mimetypes.guess_type(path)[0]] 786else: 787icon = config.unknown_icon 788 789contents = None 790if mode == "text": 791contents = convert_to_html(path) 792 793return flask.render_template( 794"repo-file.html", 795username=username, 796repository=repository, 797file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath), 798branches=all_refs, 799current=branch, 800mode=mode, 801mimetype=mimetype, 802detailedtype=magic.from_file(path), 803size=size, 804icon=icon, 805subpath=os.path.join("/", subpath), 806extension=pathlib.Path(path).suffix, 807basename=os.path.basename(path), 808contents=contents, 809remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 810is_favourite=get_favourite(flask.session.get("username"), username, repository), 811repo_data=repo_data, 812) 813 814 815@repositories.route("/<username>/<repository>/commit/<sha>") 816def repository_commit(username, repository, sha): 817server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 818if not os.path.exists(server_repo_location): 819flask.abort(404) 820if not (get_visibility(username, repository) or get_permission_level( 821flask.session.get("username"), username, 822repository) is not None): 823flask.abort(403) 824 825if not os.path.exists(server_repo_location): 826return flask.render_template("errors/not-found.html"), 404 827 828repo = git.Repo(server_repo_location) 829repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 830 831files = git_command(os.path.join(server_repo_location, ".git"), None, "diff-tree", "-r", 832"--name-only", "--no-commit-id", sha).decode().split("\n")[:-1] 833 834return flask.render_template( 835"repo-commit.html", 836username=username, 837repository=repository, 838remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 839is_favourite=get_favourite(flask.session.get("username"), username, repository), 840diff={file: git_command(os.path.join(server_repo_location, ".git"), None, "diff", 841str(sha) + "^!", "--", file).decode().split("\n") for 842file in files}, 843data=db.session.get(Commit, f"/{username}/{repository}/{sha}"), 844repo_data=repo_data, 845comment_query=Comment.query, 846permission_level=get_permission_level(flask.session.get("username"), username, repository), 847) 848 849 850@repositories.route("/<username>/<repository>/commit/<sha>/add_comment", methods=["POST"]) 851def repository_commit_add_comment(username, repository, sha): 852server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 853if not os.path.exists(server_repo_location): 854flask.abort(404) 855if not (get_visibility(username, repository) or get_permission_level( 856flask.session.get("username"), username, 857repository) is not None): 858flask.abort(403) 859 860comment = Comment( 861db.session.get(User, flask.session.get("username")), 862db.session.get(Repo, f"/{username}/{repository}"), 863db.session.get(Commit, f"/{username}/{repository}/{sha}"), 864flask.request.form["comment"], 865flask.request.form["file"], 866flask.request.form["line"], 867) 868 869db.session.add(comment) 870db.session.commit() 871 872return flask.redirect( 873flask.url_for(".repository_commit", username=username, repository=repository, sha=sha), 874code=303 875) 876 877 878@repositories.route("/<username>/<repository>/commit/<sha>/delete_comment/<int:id>", methods=["POST"]) 879def repository_commit_delete_comment(username, repository, sha, id): 880repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 881comment = Comment.query.filter_by(identifier=f"/{username}/{repository}/{id}").first() 882commit = Commit.query.filter_by(identifier=f"/{username}/{repository}/{sha}").first() 883if ( 884comment.owner.username == flask.session.get("username") 885or get_permission_level(flask.session.get("username"), username, repository) >= 2 886or comment.commit.owner.username == flask.session.get("username") 887): 888db.session.delete(comment) 889db.session.commit() 890 891return flask.redirect( 892flask.url_for(".repository_commit", username=username, repository=repository, sha=sha), 893code=303 894) 895 896 897@repositories.route("/<username>/<repository>/commit/<sha>/resolve_comment/<int:id>", methods=["POST"]) 898def repository_commit_resolve_comment(username, repository, sha, id): 899comment = Comment.query.filter_by(identifier=f"/{username}/{repository}/{id}").first() 900if ( 901comment.commit.owner.username == flask.session.get("username") 902or get_permission_level(flask.session.get("username"), username, repository) >= 2 903or comment.owner.username == flask.session.get("username") 904): 905comment.state = int(not comment.state) 906db.session.commit() 907 908return flask.redirect( 909flask.url_for(".repository_commit", username=username, repository=repository, sha=sha), 910code=303 911) 912 913 914@repositories.route("/<username>/<repository>/forum/") 915def repository_forum(username, repository): 916server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 917if not os.path.exists(server_repo_location): 918flask.abort(404) 919if not (get_visibility(username, repository) or get_permission_level( 920flask.session.get("username"), username, 921repository) is not None): 922flask.abort(403) 923 924if not os.path.exists(server_repo_location): 925return flask.render_template("errors/not-found.html"), 404 926 927repo = git.Repo(server_repo_location) 928repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 929user = User.query.filter_by(username=flask.session.get("username")).first() 930relationships = RepoAccess.query.filter_by(repo=repo_data) 931user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 932 933return flask.render_template( 934"repo-forum.html", 935username=username, 936repository=repository, 937repo_data=repo_data, 938relationships=relationships, 939repo=repo, 940user_relationship=user_relationship, 941Post=Post, 942remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 943is_favourite=get_favourite(flask.session.get("username"), username, repository), 944default_branch=repo_data.default_branch 945) 946 947 948@repositories.route("/<username>/<repository>/forum/topic/<int:id>") 949def repository_forum_topic(username, repository, id): 950server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 951if not os.path.exists(server_repo_location): 952flask.abort(404) 953if not (get_visibility(username, repository) or get_permission_level( 954flask.session.get("username"), username, 955repository) is not None): 956flask.abort(403) 957 958if not os.path.exists(server_repo_location): 959return flask.render_template("errors/not-found.html"), 404 960 961repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 962user = User.query.filter_by(username=flask.session.get("username")).first() 963relationships = RepoAccess.query.filter_by(repo=repo_data) 964user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 965 966post = Post.query.filter_by(id=id).first() 967 968return flask.render_template( 969"repo-topic.html", 970username=username, 971repository=repository, 972repo_data=repo_data, 973relationships=relationships, 974user_relationship=user_relationship, 975post=post, 976remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 977is_favourite=get_favourite(flask.session.get("username"), username, repository), 978default_branch=repo_data.default_branch 979) 980 981 982@repositories.route("/<username>/<repository>/forum/new", methods=["POST", "GET"]) 983def repository_forum_new(username, repository): 984server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 985if not os.path.exists(server_repo_location): 986flask.abort(404) 987if not (get_visibility(username, repository) or get_permission_level( 988flask.session.get("username"), username, 989repository) is not None): 990flask.abort(403) 991 992if not os.path.exists(server_repo_location): 993return flask.render_template("errors/not-found.html"), 404 994 995repo = git.Repo(server_repo_location) 996repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 997user = User.query.filter_by(username=flask.session.get("username")).first() 998relationships = RepoAccess.query.filter_by(repo=repo_data) 999user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1000 1001post = Post(user, repo_data, None, flask.request.form["subject"], 1002flask.request.form["message"]) 1003 1004db.session.add(post) 1005db.session.commit() 1006 1007return flask.redirect( 1008flask.url_for(".repository_forum_thread", username=username, repository=repository, 1009post_id=post.number), 1010code=303) 1011 1012 1013@repositories.route("/<username>/<repository>/forum/<int:post_id>") 1014def repository_forum_thread(username, repository, post_id): 1015server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1016if not os.path.exists(server_repo_location): 1017flask.abort(404) 1018if not (get_visibility(username, repository) or get_permission_level( 1019flask.session.get("username"), username, 1020repository) is not None): 1021flask.abort(403) 1022 1023if not os.path.exists(server_repo_location): 1024return flask.render_template("errors/not-found.html"), 404 1025 1026repo = git.Repo(server_repo_location) 1027repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1028user = User.query.filter_by(username=flask.session.get("username")).first() 1029relationships = RepoAccess.query.filter_by(repo=repo_data) 1030user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1031 1032if user: 1033max_post_nesting = user.max_post_nesting 1034else: 1035max_post_nesting = 2 1036 1037return flask.render_template( 1038"repo-forum-thread.html", 1039username=username, 1040repository=repository, 1041repo_data=repo_data, 1042relationships=relationships, 1043repo=repo, 1044Post=Post, 1045user_relationship=user_relationship, 1046post_id=post_id, 1047max_post_nesting=max_post_nesting, 1048remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1049is_favourite=get_favourite(flask.session.get("username"), username, repository), 1050parent=Post.query.filter_by(repo=repo_data, number=post_id).first(), 1051has_permission=not ((not get_permission_level(flask.session.get("username"), username, 1052repository)) and db.session.get(Post, 1053f"/{username}/{repository}/{post_id}").owner.username != flask.session.get("username")), 1054) 1055 1056 1057@repositories.route("/<username>/<repository>/forum/<int:post_id>/change-state", 1058methods=["POST"]) 1059def repository_forum_change_state(username, repository, post_id): 1060server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1061if not os.path.exists(server_repo_location): 1062flask.abort(404) 1063if (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"): 1064flask.abort(403) 1065 1066repo = git.Repo(server_repo_location) 1067repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1068user = User.query.filter_by(username=flask.session.get("username")).first() 1069relationships = RepoAccess.query.filter_by(repo=repo_data) 1070user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1071 1072post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1073 1074if not post: 1075flask.abort(404) 1076 1077post.state = int(flask.request.form["new-state"]) 1078 1079db.session.commit() 1080 1081return flask.redirect( 1082flask.url_for(".repository_forum_thread", username=username, repository=repository, 1083post_id=post_id), 1084code=303) 1085 1086 1087@repositories.route("/<username>/<repository>/forum/<int:post_id>/reply", methods=["POST"]) 1088def repository_forum_reply(username, repository, post_id): 1089server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1090if not os.path.exists(server_repo_location): 1091flask.abort(404) 1092if not (get_visibility(username, repository) or get_permission_level( 1093flask.session.get("username"), username, 1094repository) is not None): 1095flask.abort(403) 1096 1097if not os.path.exists(server_repo_location): 1098return flask.render_template("errors/not-found.html"), 404 1099 1100repo = git.Repo(server_repo_location) 1101repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1102user = User.query.filter_by(username=flask.session.get("username")).first() 1103relationships = RepoAccess.query.filter_by(repo=repo_data) 1104user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1105if not user: 1106flask.abort(401) 1107 1108parent = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1109post = Post(user, repo_data, parent, flask.request.form["subject"], 1110flask.request.form["message"]) 1111 1112db.session.add(post) 1113post.update_date() 1114db.session.commit() 1115 1116return flask.redirect( 1117flask.url_for(".repository_forum_thread", username=username, repository=repository, 1118post_id=post_id), 1119code=303) 1120 1121 1122@repositories.route("/<username>/<repository>/forum/<int:post_id>/edit", methods=["POST"]) 1123def repository_forum_edit(username, repository, post_id): 1124server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1125if not os.path.exists(server_repo_location): 1126flask.abort(404) 1127if not (get_visibility(username, repository) or get_permission_level( 1128flask.session.get("username"), username, 1129repository) is not None): 1130flask.abort(403) 1131 1132if not os.path.exists(server_repo_location): 1133return flask.render_template("errors/not-found.html"), 404 1134 1135repo = git.Repo(server_repo_location) 1136repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1137user = User.query.filter_by(username=flask.session.get("username")).first() 1138relationships = RepoAccess.query.filter_by(repo=repo_data) 1139user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1140if not user: 1141flask.abort(401) 1142post = db.session.get(Post, f"/{username}/{repository}/{post_id}") 1143if user != post.owner: 1144flask.abort(403) 1145 1146post.subject = flask.request.form["subject"] 1147post.message = flask.request.form["message"] 1148post.html = markdown.markdown2html(post.message).prettify() 1149post.update_date() 1150db.session.commit() 1151 1152return flask.redirect( 1153flask.url_for(".repository_forum_thread", username=username, repository=repository, 1154post_id=post_id), 1155code=303) 1156 1157 1158@repositories.route("/<username>/<repository>/forum/<int:post_id>/edit", methods=["GET"]) 1159def repository_forum_edit_form(username, repository, post_id): 1160server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1161if not os.path.exists(server_repo_location): 1162flask.abort(404) 1163if not (get_visibility(username, repository) or get_permission_level( 1164flask.session.get("username"), username, 1165repository) is not None): 1166flask.abort(403) 1167 1168if not os.path.exists(server_repo_location): 1169return flask.render_template("errors/not-found.html"), 404 1170 1171repo = git.Repo(server_repo_location) 1172repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1173user = User.query.filter_by(username=flask.session.get("username")).first() 1174relationships = RepoAccess.query.filter_by(repo=repo_data) 1175user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1176if not user: 1177flask.abort(401) 1178post = db.session.get(Post, f"/{username}/{repository}/{post_id}") 1179if user != post.owner: 1180flask.abort(403) 1181 1182return flask.render_template( 1183"repo-forum-edit.html", 1184username=username, 1185repository=repository, 1186repo_data=repo_data, 1187relationships=relationships, 1188repo=repo, 1189user_relationship=user_relationship, 1190post=post, 1191remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1192is_favourite=get_favourite(flask.session.get("username"), username, repository), 1193default_branch=repo_data.default_branch 1194) 1195 1196@repositories.route("/<username>/<repository>/forum/<int:post_id>/voteup", 1197defaults={"score": 1}) 1198@repositories.route("/<username>/<repository>/forum/<int:post_id>/votedown", 1199defaults={"score": -1}) 1200@repositories.route("/<username>/<repository>/forum/<int:post_id>/votes", defaults={"score": 0}) 1201def repository_forum_vote(username, repository, post_id, score): 1202server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1203if not os.path.exists(server_repo_location): 1204flask.abort(404) 1205if not (get_visibility(username, repository) or get_permission_level( 1206flask.session.get("username"), username, 1207repository) is not None): 1208flask.abort(403) 1209 1210if not os.path.exists(server_repo_location): 1211return flask.render_template("errors/not-found.html"), 404 1212 1213repo = git.Repo(server_repo_location) 1214repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1215user = User.query.filter_by(username=flask.session.get("username")).first() 1216relationships = RepoAccess.query.filter_by(repo=repo_data) 1217user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1218if not user: 1219flask.abort(401) 1220 1221post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1222 1223if score: 1224old_relationship = PostVote.query.filter_by(user_username=user.username, 1225post_identifier=post.identifier).first() 1226if old_relationship: 1227if score == old_relationship.vote_score: 1228db.session.delete(old_relationship) 1229post.vote_sum -= old_relationship.vote_score 1230else: 1231post.vote_sum -= old_relationship.vote_score 1232post.vote_sum += score 1233old_relationship.vote_score = score 1234else: 1235relationship = PostVote(user, post, score) 1236post.vote_sum += score 1237db.session.add(relationship) 1238 1239db.session.commit() 1240 1241user_vote = PostVote.query.filter_by(user_username=user.username, 1242post_identifier=post.identifier).first() 1243response = flask.make_response( 1244str(post.vote_sum) + " " + str(user_vote.vote_score if user_vote else 0)) 1245response.content_type = "text/plain" 1246 1247return response 1248 1249 1250@repositories.route("/<username>/<repository>/favourite") 1251def repository_favourite(username, repository): 1252server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1253if not os.path.exists(server_repo_location): 1254flask.abort(404) 1255if not (get_visibility(username, repository) or get_permission_level( 1256flask.session.get("username"), username, 1257repository) is not None): 1258flask.abort(403) 1259 1260if not os.path.exists(server_repo_location): 1261return flask.render_template("errors/not-found.html"), 404 1262 1263repo = git.Repo(server_repo_location) 1264repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1265user = User.query.filter_by(username=flask.session.get("username")).first() 1266relationships = RepoAccess.query.filter_by(repo=repo_data) 1267user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1268if not user: 1269flask.abort(401) 1270 1271old_relationship = RepoFavourite.query.filter_by(user_username=user.username, 1272repo_route=repo_data.route).first() 1273if old_relationship: 1274db.session.delete(old_relationship) 1275else: 1276relationship = RepoFavourite(user, repo_data) 1277db.session.add(relationship) 1278 1279db.session.commit() 1280 1281return flask.redirect(flask.url_for("favourites"), code=303) 1282 1283 1284@repositories.route("/<username>/<repository>/users/", methods=["GET", "POST"]) 1285def repository_users(username, repository): 1286server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1287if not os.path.exists(server_repo_location): 1288flask.abort(404) 1289if not (get_visibility(username, repository) or get_permission_level( 1290flask.session.get("username"), username, 1291repository) is not None): 1292flask.abort(403) 1293 1294if not os.path.exists(server_repo_location): 1295return flask.render_template("errors/not-found.html"), 404 1296 1297repo = git.Repo(server_repo_location) 1298repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1299user = User.query.filter_by(username=flask.session.get("username")).first() 1300relationships = RepoAccess.query.filter_by(repo=repo_data) 1301user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1302 1303if flask.request.method == "GET": 1304return flask.render_template( 1305"repo-users.html", 1306username=username, 1307repository=repository, 1308repo_data=repo_data, 1309relationships=relationships, 1310repo=repo, 1311user_relationship=user_relationship, 1312remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1313is_favourite=get_favourite(flask.session.get("username"), username, repository) 1314) 1315else: 1316if get_permission_level(flask.session.get("username"), username, repository) != 2: 1317flask.abort(401) 1318 1319if flask.request.form.get("new-username"): 1320# Create new relationship 1321new_user = User.query.filter_by( 1322username=flask.request.form.get("new-username")).first() 1323relationship = RepoAccess(new_user, repo_data, flask.request.form.get("new-level")) 1324db.session.add(relationship) 1325db.session.commit() 1326if flask.request.form.get("update-username"): 1327# Create new relationship 1328updated_user = User.query.filter_by( 1329username=flask.request.form.get("update-username")).first() 1330relationship = RepoAccess.query.filter_by(repo=repo_data, user=updated_user).first() 1331if flask.request.form.get("update-level") == -1: 1332relationship.delete() 1333else: 1334relationship.access_level = flask.request.form.get("update-level") 1335db.session.commit() 1336 1337return flask.redirect( 1338app.url_for(".repository_users", username=username, repository=repository)) 1339 1340 1341@repositories.route("/<username>/<repository>/branches/") 1342def repository_branches(username, repository): 1343server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1344if not os.path.exists(server_repo_location): 1345flask.abort(404) 1346if not (get_visibility(username, repository) or get_permission_level( 1347flask.session.get("username"), username, 1348repository) is not None): 1349flask.abort(403) 1350 1351if not os.path.exists(server_repo_location): 1352return flask.render_template("errors/not-found.html"), 404 1353 1354repo = git.Repo(server_repo_location) 1355repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1356 1357return flask.render_template( 1358"repo-branches.html", 1359username=username, 1360repository=repository, 1361repo_data=repo_data, 1362repo=repo, 1363remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1364is_favourite=get_favourite(flask.session.get("username"), username, repository) 1365) 1366 1367 1368@repositories.route("/<username>/<repository>/log/", defaults={"branch": None}) 1369@repositories.route("/<username>/<repository>/log/<branch>/") 1370def repository_log(username, repository, branch): 1371server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1372if not os.path.exists(server_repo_location): 1373flask.abort(404) 1374if not (get_visibility(username, repository) or get_permission_level( 1375flask.session.get("username"), username, 1376repository) is not None): 1377flask.abort(403) 1378 1379if not os.path.exists(server_repo_location): 1380return flask.render_template("errors/not-found.html"), 404 1381 1382repo = git.Repo(server_repo_location) 1383repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1384if not repo_data.default_branch: 1385if repo.heads: 1386repo_data.default_branch = repo.heads[0].name 1387else: 1388return flask.render_template("empty.html", 1389remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200 1390if not branch: 1391branch = repo_data.default_branch 1392return flask.redirect(f"./{branch}", code=302) 1393 1394if branch.startswith("tag:"): 1395ref = f"tags/{branch[4:]}" 1396elif branch.startswith("~"): 1397ref = branch[1:] 1398else: 1399ref = f"heads/{branch}" 1400 1401ref = ref.replace("~", "/") # encode slashes for URL support 1402 1403try: 1404repo.git.checkout("-f", ref) 1405except git.exc.GitCommandError: 1406return flask.render_template("errors/not-found.html"), 404 1407 1408branches = repo.heads 1409 1410all_refs = [] 1411for ref in repo.heads: 1412all_refs.append((ref, "head")) 1413for ref in repo.tags: 1414all_refs.append((ref, "tag")) 1415 1416commit_list = [f"/{username}/{repository}/{sha}" for sha in 1417git_command(server_repo_location, None, "log", 1418"--format='%H'").decode().split("\n")] 1419 1420commits = Commit.query.filter(Commit.identifier.in_(commit_list)).order_by(Commit.author_date.desc()) 1421page_number = flask.request.args.get("page", 1, type=int) 1422if flask.session.get("username"): 1423default_page_length = db.session.get(User, flask.session.get("username")).default_page_length 1424else: 1425default_page_length = 16 1426page_length = flask.request.args.get("per_page", default_page_length, type=int) 1427page_listing = db.paginate(commits, page=page_number, per_page=page_length) 1428 1429if page_listing.has_next: 1430next_page = page_listing.next_num 1431else: 1432next_page = None 1433 1434if page_listing.has_prev: 1435prev_page = page_listing.prev_num 1436else: 1437prev_page = None 1438 1439return flask.render_template( 1440"repo-log.html", 1441username=username, 1442repository=repository, 1443branches=all_refs, 1444current=branch, 1445repo_data=repo_data, 1446repo=repo, 1447commits=page_listing, 1448remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1449is_favourite=get_favourite(flask.session.get("username"), username, repository), 1450page_number=page_number, 1451page_length=page_length, 1452next_page=next_page, 1453prev_page=prev_page, 1454num_pages=page_listing.pages 1455) 1456 1457 1458@repositories.route("/<username>/<repository>/prs/", methods=["GET", "POST"]) 1459def repository_prs(username, repository): 1460server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1461if not os.path.exists(server_repo_location): 1462flask.abort(404) 1463if not (get_visibility(username, repository) or get_permission_level( 1464flask.session.get("username"), username, 1465repository) is not None): 1466flask.abort(403) 1467 1468if not os.path.exists(server_repo_location): 1469return flask.render_template("errors/not-found.html"), 404 1470 1471if flask.request.method == "GET": 1472repo = git.Repo(server_repo_location) 1473repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1474user = User.query.filter_by(username=flask.session.get("username")).first() 1475 1476return flask.render_template( 1477"repo-prs.html", 1478username=username, 1479repository=repository, 1480repo_data=repo_data, 1481repo=repo, 1482PullRequest=PullRequest, 1483remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1484is_favourite=get_favourite(flask.session.get("username"), username, repository), 1485default_branch=repo_data.default_branch, 1486branches=repo.branches 1487) 1488 1489else: 1490repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1491head = flask.request.form.get("head") 1492head_route = flask.request.form.get("headroute") 1493base = flask.request.form.get("base") 1494 1495if not head and base and head_route: 1496return flask.redirect(".", 400) 1497 1498head_repo = git.Repo(os.path.join(config.REPOS_PATH, head_route.lstrip("/"))) 1499base_repo = git.Repo(server_repo_location) 1500 1501if head not in head_repo.branches or base not in base_repo.branches: 1502flask.flash(Markup( 1503"<iconify-icon icon='mdi:error'></iconify-icon>" + _("Bad branch name")), 1504category="error") 1505return flask.redirect(".", 303) 1506 1507head_data = db.session.get(Repo, head_route) 1508if not head_data.visibility: 1509flask.flash(Markup( 1510"<iconify-icon icon='mdi:error'></iconify-icon>" + _( 1511"Head can't be restricted")), 1512category="error") 1513return flask.redirect(".", 303) 1514 1515pull_request = PullRequest(head_data, head, repo_data, base, 1516db.session.get(User, flask.session["username"])) 1517 1518db.session.add(pull_request) 1519db.session.commit() 1520 1521# Create the notification 1522notification = Notification({"type": "pr", "head": pull_request.head.route, "base": pull_request.base.route, "pr": pull_request.id}) 1523db.session.add(notification) 1524db.session.commit() 1525 1526# Send a notification to all users who have enabled PR notifications for this repo 1527for relationship in RepoFavourite.query.filter_by(repo_route=pull_request.base.route, notify_pr=True).all(): 1528user = relationship.user 1529user_notification = UserNotification(user, notification, 1) 1530db.session.add(user_notification) 1531db.session.commit() 1532celery_tasks.send_notification.apply_async(args=[user_notification.id]) 1533 1534return flask.redirect(".", 303) 1535 1536 1537@repositories.route("/<username>/<repository>/prs/merge", methods=["POST"]) 1538def repository_prs_merge(username, repository): 1539server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1540if not os.path.exists(server_repo_location): 1541flask.abort(404) 1542if not (get_visibility(username, repository) or get_permission_level( 1543flask.session.get("username"), username, 1544repository) is not None): 1545flask.abort(403) 1546 1547if not get_permission_level(flask.session.get("username"), username, repository): 1548flask.abort(401) 1549 1550repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1551repo = git.Repo(server_repo_location) 1552id = flask.request.form.get("id") 1553 1554pull_request = db.session.get(PullRequest, id) 1555 1556if pull_request: 1557result = celery_tasks.merge_heads.delay( 1558pull_request.head_route, 1559pull_request.head_branch, 1560pull_request.base_route, 1561pull_request.base_branch, 1562simulate=True 1563) 1564task_result = worker.AsyncResult(result.id) 1565 1566return flask.redirect(f"/task/{result.id}?pr-id={id}", 303) # should be 202 Accepted but we must use a redirect 1567# db.session.delete(pull_request) 1568# db.session.commit() 1569else: 1570flask.abort(400) 1571 1572 1573@repositories.route("/<username>/<repository>/prs/<int:id>/merge") 1574def repository_prs_merge_stage_two(username, repository, id): 1575server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1576if not os.path.exists(server_repo_location): 1577flask.abort(404) 1578if not (get_visibility(username, repository) or get_permission_level( 1579flask.session.get("username"), username, 1580repository) is not None): 1581flask.abort(403) 1582 1583if not get_permission_level(flask.session.get("username"), username, repository): 1584flask.abort(401) 1585 1586repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1587repo = git.Repo(server_repo_location) 1588 1589pull_request = db.session.get(PullRequest, id) 1590 1591if pull_request: 1592result = celery_tasks.merge_heads.delay( 1593pull_request.head_route, 1594pull_request.head_branch, 1595pull_request.base_route, 1596pull_request.base_branch, 1597simulate=False 1598) 1599task_result = worker.AsyncResult(result.id) 1600 1601pull_request.state = 1 1602db.session.commit() 1603 1604return flask.redirect(f"/task/{result.id}?pr-id={id}", 303) 1605# db.session.delete(pull_request) 1606else: 1607flask.abort(400) 1608 1609 1610@app.route("/task/<task_id>") 1611def task_monitor(task_id): 1612task_result = worker.AsyncResult(task_id) 1613 1614if flask.request.args.get("partial"): 1615# htmx partial update 1616return render_block("task-monitor.html", "content", result=task_result, query_string=flask.request.query_string.decode(), delay=1000) 1617 1618# Since most tasks finish rather quickly, the initial delay is faster, so it doesn't wait for too long 1619return flask.render_template("task-monitor.html", result=task_result, query_string=flask.request.query_string.decode(), delay=125) 1620 1621 1622@repositories.route("/<username>/<repository>/prs/delete", methods=["POST"]) 1623def repository_prs_delete(username, repository): 1624server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1625if not os.path.exists(server_repo_location): 1626flask.abort(404) 1627if not (get_visibility(username, repository) or get_permission_level( 1628flask.session.get("username"), username, 1629repository) is not None): 1630flask.abort(403) 1631 1632if not get_permission_level(flask.session.get("username"), username, repository): 1633flask.abort(401) 1634 1635repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1636repo = git.Repo(server_repo_location) 1637id = flask.request.form.get("id") 1638 1639pull_request = db.session.get(PullRequest, id) 1640 1641if pull_request: 1642pull_request.state = 2 1643db.session.commit() 1644 1645return flask.redirect(".", 303) 1646 1647 1648@repositories.route("/<username>/<repository>/settings/") 1649def repository_settings(username, repository): 1650if get_permission_level(flask.session.get("username"), username, repository) != 2: 1651flask.abort(401) 1652 1653repo = git.Repo(os.path.join(config.REPOS_PATH, username, repository)) 1654 1655site_link = Markup(f"<code>http{'s' if config.suggest_https else ''}://{username}.{config.BASE_DOMAIN}/{repository}</code>") 1656primary_site_link = Markup(f"<code>http{'s' if config.suggest_https else ''}://{username}.{config.BASE_DOMAIN}/</code>") 1657 1658return flask.render_template("repo-settings.html", username=username, repository=repository, 1659repo_data=db.session.get(Repo, f"/{username}/{repository}"), 1660branches=[branch.name for branch in repo.branches], 1661site_link=site_link, primary_site_link=primary_site_link, 1662remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1663is_favourite=get_favourite(flask.session.get("username"), username, repository), 1664) 1665 1666 1667@repositories.route("/<username>/<repository>/settings/", methods=["POST"]) 1668def repository_settings_post(username, repository): 1669if get_permission_level(flask.session.get("username"), username, repository) != 2: 1670flask.abort(401) 1671 1672repo = db.session.get(Repo, f"/{username}/{repository}") 1673 1674repo.visibility = flask.request.form.get("visibility", type=int) 1675repo.info = flask.request.form.get("description") 1676repo.default_branch = flask.request.form.get("default_branch") 1677repo.url = flask.request.form.get("url") 1678 1679# Update site settings 1680had_site = repo.has_site 1681old_branch = repo.site_branch 1682if flask.request.form.get("site_branch"): 1683repo.site_branch = flask.request.form.get("site_branch") 1684if flask.request.form.get("primary_site"): 1685if had_site != 2: 1686# Remove primary site from other repos 1687for other_repo in Repo.query.filter_by(owner=repo.owner, has_site=2): 1688other_repo.has_site = 1 # switch it to a regular site 1689flask.flash(Markup( 1690_("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( 1691repository=other_repo.route 1692)), category="warning") 1693repo.has_site = 2 1694else: 1695repo.has_site = 1 1696else: 1697repo.site_branch = None 1698repo.has_site = 0 1699 1700db.session.commit() 1701 1702if not (had_site, old_branch) == (repo.has_site, repo.site_branch): 1703# Deploy the newly activated site 1704result = celery_tasks.copy_site.delay(repo.route) 1705 1706if had_site and not repo.has_site: 1707# Remove the site 1708result = celery_tasks.delete_site.delay(repo.route) 1709 1710if repo.has_site == 2 or (had_site == 2 and had_site != repo.has_site): 1711# Deploy all other sites which were destroyed by the primary site 1712for other_repo in Repo.query.filter_by(owner=repo.owner, has_site=1): 1713result = celery_tasks.copy_site.delay(other_repo.route) 1714 1715return flask.redirect(f"/{username}/{repository}/settings", 303) 1716 1717 1718@app.errorhandler(404) 1719def e404(error): 1720return flask.render_template("errors/not-found.html"), 404 1721 1722 1723@app.errorhandler(401) 1724def e401(error): 1725return flask.render_template("errors/unauthorised.html"), 401 1726 1727 1728@app.errorhandler(403) 1729def e403(error): 1730return flask.render_template("errors/forbidden.html"), 403 1731 1732 1733@app.errorhandler(418) 1734def e418(error): 1735return flask.render_template("errors/teapot.html"), 418 1736 1737 1738@app.errorhandler(405) 1739def e405(error): 1740return flask.render_template("errors/method-not-allowed.html"), 405 1741 1742 1743@app.errorhandler(500) 1744def e500(error): 1745return flask.render_template("errors/server-error.html"), 500 1746 1747 1748@app.errorhandler(400) 1749def e400(error): 1750return flask.render_template("errors/bad-request.html"), 400 1751 1752 1753@app.errorhandler(410) 1754def e410(error): 1755return flask.render_template("errors/gone.html"), 410 1756 1757 1758@app.errorhandler(415) 1759def e415(error): 1760return flask.render_template("errors/media-type.html"), 415 1761 1762 1763if __name__ == "__main__": 1764app.run(debug=True, port=8080, host="0.0.0.0") 1765 1766app.register_blueprint(repositories) 1767