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