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