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 664response = flask.send_from_directory(config.REPOS_PATH, 665os.path.join(username, repository, subpath)) 666 667if repo_data.visibility < 2: 668response.headers["X-Robots-Tag"] = "noindex" 669 670return response 671 672 673@repositories.route("/<username>/<repository>/tree/", defaults={"branch": None, "subpath": ""}) 674@repositories.route("/<username>/<repository>/tree/<branch>/", defaults={"subpath": ""}) 675@repositories.route("/<username>/<repository>/tree/<branch>/<path:subpath>") 676def repository_tree(username, repository, branch, subpath): 677server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 678if not os.path.exists(server_repo_location): 679flask.abort(404) 680if not (get_visibility(username, repository) or get_permission_level( 681flask.session.get("username"), username, 682repository) is not None): 683flask.abort(403) 684 685repo = git.Repo(server_repo_location) 686repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 687if not repo_data.default_branch: 688if repo.heads: 689repo_data.default_branch = repo.heads[0].name 690else: 691return flask.render_template("empty.html", 692remote=f"{config.www_protocol}://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200 693if not branch: 694branch = repo_data.default_branch 695return flask.redirect(f"./{branch}", code=302) 696 697if branch.startswith("tag:"): 698ref = f"tags/{branch[4:]}" 699elif branch.startswith("~"): 700ref = branch[1:] 701else: 702ref = f"heads/{branch}" 703 704ref = ref.replace("~", "/") # encode slashes for URL support 705 706try: 707repo.git.checkout("-f", ref) 708except git.exc.GitCommandError: 709return flask.render_template("errors/not-found.html"), 404 710 711branches = repo.heads 712 713all_refs = [] 714for ref in repo.heads: 715all_refs.append((ref, "head")) 716for ref in repo.tags: 717all_refs.append((ref, "tag")) 718 719if os.path.isdir(os.path.join(server_repo_location, subpath)): 720files = [] 721blobs = [] 722 723for entry in os.listdir(os.path.join(server_repo_location, subpath)): 724if not os.path.basename(entry) == ".git": 725files.append(os.path.join(subpath, entry)) 726 727infos = [] 728 729for file in files: 730path = os.path.join(server_repo_location, file) 731mimetype = guess_mime(path) 732 733text = git_command(server_repo_location, None, "log", "--format='%H\n'", 734shlex.quote(file)).decode() 735 736sha = text.split("\n")[0] 737identifier = f"/{username}/{repository}/{sha}" 738 739last_commit = db.session.get(Commit, identifier) 740 741info = { 742"name": os.path.basename(file), 743"serverPath": path, 744"relativePath": file, 745"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file), 746"size": human_size(os.path.getsize(path)), 747"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}", 748"commit": last_commit, 749"shaSize": 7, 750} 751 752special_icon = config.match_icon(os.path.basename(file)) 753if special_icon: 754info["icon"] = special_icon 755elif os.path.isdir(path): 756info["icon"] = config.folder_icon 757info["size"] = _("{} files").format(len(os.listdir(path))) 758elif mimetypes.guess_type(path)[0] in config.file_icons: 759info["icon"] = config.file_icons[mimetypes.guess_type(path)[0]] 760else: 761info["icon"] = config.unknown_icon 762 763if os.path.isdir(path): 764infos.insert(0, info) 765else: 766infos.append(info) 767 768return flask.render_template( 769"repo-tree.html", 770username=username, 771repository=repository, 772files=infos, 773subpath=os.path.join("/", subpath), 774branches=all_refs, 775current=branch, 776remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 777is_favourite=get_favourite(flask.session.get("username"), username, repository), 778repo_data=repo_data, 779) 780else: 781path = os.path.join(server_repo_location, subpath) 782 783if not os.path.exists(path): 784return flask.render_template("errors/not-found.html"), 404 785 786mimetype = guess_mime(path) 787mode = mimetype.split("/", 1)[0] 788size = human_size(os.path.getsize(path)) 789 790special_icon = config.match_icon(os.path.basename(path)) 791if special_icon: 792icon = special_icon 793elif os.path.isdir(path): 794icon = config.folder_icon 795elif mimetypes.guess_type(path)[0] in config.file_icons: 796icon = config.file_icons[mimetypes.guess_type(path)[0]] 797else: 798icon = config.unknown_icon 799 800contents = None 801if mode == "text": 802contents = convert_to_html(path) 803 804return flask.render_template( 805"repo-file.html", 806username=username, 807repository=repository, 808file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath), 809branches=all_refs, 810current=branch, 811mode=mode, 812mimetype=mimetype, 813detailedtype=magic.from_file(path), 814size=size, 815icon=icon, 816subpath=os.path.join("/", subpath), 817extension=pathlib.Path(path).suffix, 818basename=os.path.basename(path), 819contents=contents, 820remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 821is_favourite=get_favourite(flask.session.get("username"), username, repository), 822repo_data=repo_data, 823) 824 825 826@repositories.route("/<username>/<repository>/commit/<sha>") 827def repository_commit(username, repository, sha): 828server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 829if not os.path.exists(server_repo_location): 830flask.abort(404) 831if not (get_visibility(username, repository) or get_permission_level( 832flask.session.get("username"), username, 833repository) is not None): 834flask.abort(403) 835 836if not os.path.exists(server_repo_location): 837return flask.render_template("errors/not-found.html"), 404 838 839repo = git.Repo(server_repo_location) 840repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 841 842files = git_command(os.path.join(server_repo_location, ".git"), None, "diff-tree", "-r", 843"--name-only", "--no-commit-id", sha).decode().split("\n")[:-1] 844 845return flask.render_template( 846"repo-commit.html", 847username=username, 848repository=repository, 849remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 850is_favourite=get_favourite(flask.session.get("username"), username, repository), 851diff={file: git_command(os.path.join(server_repo_location, ".git"), None, "diff", 852str(sha) + "^!", "--", file).decode().split("\n") for 853file in files}, 854data=db.session.get(Commit, f"/{username}/{repository}/{sha}"), 855repo_data=repo_data, 856comment_query=Comment.query, 857permission_level=get_permission_level(flask.session.get("username"), username, repository), 858) 859 860 861@repositories.route("/<username>/<repository>/commit/<sha>/add_comment", methods=["POST"]) 862def repository_commit_add_comment(username, repository, sha): 863server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 864if not os.path.exists(server_repo_location): 865flask.abort(404) 866if not (get_visibility(username, repository) or get_permission_level( 867flask.session.get("username"), username, 868repository) is not None): 869flask.abort(403) 870 871comment = Comment( 872db.session.get(User, flask.session.get("username")), 873db.session.get(Repo, f"/{username}/{repository}"), 874db.session.get(Commit, f"/{username}/{repository}/{sha}"), 875flask.request.form["comment"], 876flask.request.form["file"], 877flask.request.form["line"], 878) 879 880db.session.add(comment) 881db.session.commit() 882 883return flask.redirect( 884flask.url_for(".repository_commit", username=username, repository=repository, sha=sha), 885code=303 886) 887 888 889@repositories.route("/<username>/<repository>/commit/<sha>/delete_comment/<int:id>", methods=["POST"]) 890def repository_commit_delete_comment(username, repository, sha, id): 891repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 892comment = Comment.query.filter_by(identifier=f"/{username}/{repository}/{id}").first() 893commit = Commit.query.filter_by(identifier=f"/{username}/{repository}/{sha}").first() 894if ( 895comment.owner.username == flask.session.get("username") 896or get_permission_level(flask.session.get("username"), username, repository) >= 2 897or comment.commit.owner.username == flask.session.get("username") 898): 899db.session.delete(comment) 900db.session.commit() 901 902return flask.redirect( 903flask.url_for(".repository_commit", username=username, repository=repository, sha=sha), 904code=303 905) 906 907 908@repositories.route("/<username>/<repository>/commit/<sha>/resolve_comment/<int:id>", methods=["POST"]) 909def repository_commit_resolve_comment(username, repository, sha, id): 910comment = Comment.query.filter_by(identifier=f"/{username}/{repository}/{id}").first() 911if ( 912comment.commit.owner.username == flask.session.get("username") 913or get_permission_level(flask.session.get("username"), username, repository) >= 2 914or comment.owner.username == flask.session.get("username") 915): 916comment.state = int(not comment.state) 917db.session.commit() 918 919return flask.redirect( 920flask.url_for(".repository_commit", username=username, repository=repository, sha=sha), 921code=303 922) 923 924 925@repositories.route("/<username>/<repository>/forum/") 926def repository_forum(username, repository): 927server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 928if not os.path.exists(server_repo_location): 929flask.abort(404) 930if not (get_visibility(username, repository) or get_permission_level( 931flask.session.get("username"), username, 932repository) is not None): 933flask.abort(403) 934 935repo = git.Repo(server_repo_location) 936repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 937user = User.query.filter_by(username=flask.session.get("username")).first() 938relationships = RepoAccess.query.filter_by(repo=repo_data) 939user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 940 941return flask.render_template( 942"repo-forum.html", 943username=username, 944repository=repository, 945repo_data=repo_data, 946relationships=relationships, 947repo=repo, 948user_relationship=user_relationship, 949Post=Post, 950remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 951is_favourite=get_favourite(flask.session.get("username"), username, repository), 952default_branch=repo_data.default_branch 953) 954 955 956@repositories.route("/<username>/<repository>/forum/search") 957def repository_forum_search(username, repository): 958server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 959if not os.path.exists(server_repo_location): 960flask.abort(404) 961if not (get_visibility(username, repository) or get_permission_level( 962flask.session.get("username"), username, 963repository) is not None): 964flask.abort(403) 965 966repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 967user = User.query.filter_by(username=flask.session.get("username")).first() 968relationships = RepoAccess.query.filter_by(repo=repo_data) 969user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 970 971query = flask.request.args.get("q") 972 973page_number = flask.request.args.get("page", 1, type=int) 974if flask.session.get("username"): 975default_page_length = db.session.get(User, flask.session.get("username")).default_page_length 976else: 977default_page_length = 16 978 979page_length = flask.request.args.get("per_page", default_page_length, type=int) 980 981all_posts = Post.query.filter(Post.repo == repo_data) 982 983results = (all_posts 984.filter(Post.subject.ilike(f"%{query}%") | Post.message.ilike(f"%{query}%")) 985.order_by(Post.last_updated.desc())) 986 987if flask.request.args.get("state"): 988try: 989results = results.filter(Post.state == int(flask.request.args.get("state"))) 990except ValueError: 991pass # if state is not an integer, ignore it 992 993if flask.request.args.get("label"): 994results = results.filter(Post.labels.any(Label.identifier == flask.request.args.get("label"))) 995 996results = results.paginate(page=page_number, per_page=page_length) 997 998if results.has_next: 999next_page = results.next_num 1000else: 1001next_page = None 1002 1003if results.has_prev: 1004prev_page = results.prev_num 1005else: 1006prev_page = None 1007 1008return flask.render_template( 1009"repo-forum-search.html", 1010username=username, 1011repository=repository, 1012repo_data=repo_data, 1013relationships=relationships, 1014user_relationship=user_relationship, 1015query=query, 1016results=results, 1017Post=Post, 1018remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1019is_favourite=get_favourite(flask.session.get("username"), username, repository), 1020default_branch=repo_data.default_branch, 1021page_number=page_number, 1022page_length=page_length, 1023next_page=next_page, 1024prev_page=prev_page, 1025num_pages=results.pages, 1026require_state=flask.request.args.get("state"), 1027require_label=flask.request.args.get("label"), 1028) 1029 1030 1031@repositories.route("/<username>/<repository>/forum/topic/<int:id>") 1032def repository_forum_topic(username, repository, id): 1033server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1034if not os.path.exists(server_repo_location): 1035flask.abort(404) 1036if not (get_visibility(username, repository) or get_permission_level( 1037flask.session.get("username"), username, 1038repository) is not None): 1039flask.abort(403) 1040 1041if not os.path.exists(server_repo_location): 1042return flask.render_template("errors/not-found.html"), 404 1043 1044repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1045user = User.query.filter_by(username=flask.session.get("username")).first() 1046relationships = RepoAccess.query.filter_by(repo=repo_data) 1047user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1048 1049post = Post.query.filter_by(id=id).first() 1050 1051return flask.render_template( 1052"repo-topic.html", 1053username=username, 1054repository=repository, 1055repo_data=repo_data, 1056relationships=relationships, 1057user_relationship=user_relationship, 1058post=post, 1059remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1060is_favourite=get_favourite(flask.session.get("username"), username, repository), 1061default_branch=repo_data.default_branch 1062) 1063 1064 1065@repositories.route("/<username>/<repository>/forum/new", methods=["POST", "GET"]) 1066def repository_forum_new(username, repository): 1067server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1068if not os.path.exists(server_repo_location): 1069flask.abort(404) 1070if not ((flask.session.get("username") and get_visibility(username, repository)) or get_permission_level( 1071flask.session.get("username"), username, 1072repository) is not None): 1073flask.abort(403) 1074 1075if not os.path.exists(server_repo_location): 1076return flask.render_template("errors/not-found.html"), 404 1077 1078repo = git.Repo(server_repo_location) 1079repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1080user = User.query.filter_by(username=flask.session.get("username")).first() 1081relationships = RepoAccess.query.filter_by(repo=repo_data) 1082user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1083 1084post = Post(user, repo_data, None, flask.request.form["subject"], 1085flask.request.form["message"]) 1086 1087db.session.add(post) 1088db.session.commit() 1089 1090return flask.redirect( 1091flask.url_for(".repository_forum_thread", username=username, repository=repository, 1092post_id=post.number), 1093code=303) 1094 1095 1096@repositories.route("/<username>/<repository>/forum/<int:post_id>") 1097def repository_forum_thread(username, repository, post_id): 1098server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1099if not os.path.exists(server_repo_location): 1100flask.abort(404) 1101if not (get_visibility(username, repository) or get_permission_level( 1102flask.session.get("username"), username, 1103repository) is not None): 1104flask.abort(403) 1105 1106if not os.path.exists(server_repo_location): 1107return flask.render_template("errors/not-found.html"), 404 1108 1109repo = git.Repo(server_repo_location) 1110repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1111user = User.query.filter_by(username=flask.session.get("username")).first() 1112relationships = RepoAccess.query.filter_by(repo=repo_data) 1113user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1114 1115if user: 1116max_post_nesting = user.max_post_nesting 1117else: 1118max_post_nesting = 2 1119 1120return flask.render_template( 1121"repo-forum-thread.html", 1122username=username, 1123repository=repository, 1124repo_data=repo_data, 1125relationships=relationships, 1126repo=repo, 1127Post=Post, 1128user_relationship=user_relationship, 1129post_id=post_id, 1130max_post_nesting=max_post_nesting, 1131remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1132is_favourite=get_favourite(flask.session.get("username"), username, repository), 1133parent=Post.query.filter_by(repo=repo_data, number=post_id).first(), 1134has_permission=not ((not get_permission_level(flask.session.get("username"), username, 1135repository)) and db.session.get(Post, 1136f"/{username}/{repository}/{post_id}").owner.username != flask.session.get("username")), 1137) 1138 1139 1140@repositories.route("/<username>/<repository>/forum/<int:post_id>/change-state", 1141methods=["POST"]) 1142def repository_forum_change_state(username, repository, post_id): 1143server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1144if not os.path.exists(server_repo_location): 1145flask.abort(404) 1146if (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"): 1147flask.abort(403) 1148 1149repo = git.Repo(server_repo_location) 1150repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1151user = User.query.filter_by(username=flask.session.get("username")).first() 1152relationships = RepoAccess.query.filter_by(repo=repo_data) 1153user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1154 1155post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1156 1157if not post: 1158flask.abort(404) 1159if post.parent: 1160flask.abort(400) 1161 1162post.state = int(flask.request.form["new-state"]) 1163 1164db.session.commit() 1165 1166return flask.redirect( 1167flask.url_for(".repository_forum_thread", username=username, repository=repository, 1168post_id=post_id), 1169code=303) 1170 1171 1172@repositories.route("/<username>/<repository>/forum/<int:post_id>/reply", methods=["POST"]) 1173def repository_forum_reply(username, repository, post_id): 1174server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1175if not os.path.exists(server_repo_location): 1176flask.abort(404) 1177if not ((flask.session.get("username") and get_visibility(username, repository)) or get_permission_level( 1178flask.session.get("username"), username, 1179repository) is not None): 1180flask.abort(403) 1181 1182if not os.path.exists(server_repo_location): 1183return flask.render_template("errors/not-found.html"), 404 1184 1185repo = git.Repo(server_repo_location) 1186repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1187user = User.query.filter_by(username=flask.session.get("username")).first() 1188relationships = RepoAccess.query.filter_by(repo=repo_data) 1189user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1190if not user: 1191flask.abort(401) 1192 1193parent = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1194post = Post(user, repo_data, parent, flask.request.form["subject"], 1195flask.request.form["message"]) 1196 1197db.session.add(post) 1198post.update_date() 1199db.session.commit() 1200 1201return flask.redirect( 1202flask.url_for(".repository_forum_thread", username=username, repository=repository, 1203post_id=post_id), 1204code=303) 1205 1206 1207@repositories.route("/<username>/<repository>/forum/<int:post_id>/edit", methods=["POST"]) 1208def repository_forum_edit(username, repository, post_id): 1209server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1210if not os.path.exists(server_repo_location): 1211flask.abort(404) 1212if not (get_visibility(username, repository) or get_permission_level( 1213flask.session.get("username"), username, 1214repository) is not None): 1215flask.abort(403) 1216 1217if not os.path.exists(server_repo_location): 1218return flask.render_template("errors/not-found.html"), 404 1219 1220repo = git.Repo(server_repo_location) 1221repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1222user = User.query.filter_by(username=flask.session.get("username")).first() 1223relationships = RepoAccess.query.filter_by(repo=repo_data) 1224user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1225if not user: 1226flask.abort(401) 1227post = db.session.get(Post, f"/{username}/{repository}/{post_id}") 1228if user != post.owner: 1229flask.abort(403) 1230 1231post.subject = flask.request.form["subject"] 1232post.message = flask.request.form["message"] 1233post.html = markdown.markdown2html(post.message).prettify() 1234post.update_date() 1235db.session.commit() 1236 1237return flask.redirect( 1238flask.url_for(".repository_forum_thread", username=username, repository=repository, 1239post_id=post_id), 1240code=303) 1241 1242 1243@repositories.route("/<username>/<repository>/forum/<int:post_id>/edit", methods=["GET"]) 1244def repository_forum_edit_form(username, repository, post_id): 1245server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1246if not os.path.exists(server_repo_location): 1247flask.abort(404) 1248if not (get_visibility(username, repository) or get_permission_level( 1249flask.session.get("username"), username, 1250repository) is not None): 1251flask.abort(403) 1252 1253if not os.path.exists(server_repo_location): 1254return flask.render_template("errors/not-found.html"), 404 1255 1256repo = git.Repo(server_repo_location) 1257repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1258user = User.query.filter_by(username=flask.session.get("username")).first() 1259relationships = RepoAccess.query.filter_by(repo=repo_data) 1260user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1261if not user: 1262flask.abort(401) 1263post = db.session.get(Post, f"/{username}/{repository}/{post_id}") 1264if user != post.owner: 1265flask.abort(403) 1266 1267return flask.render_template( 1268"repo-forum-edit.html", 1269username=username, 1270repository=repository, 1271repo_data=repo_data, 1272relationships=relationships, 1273repo=repo, 1274user_relationship=user_relationship, 1275post=post, 1276remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1277is_favourite=get_favourite(flask.session.get("username"), username, repository), 1278default_branch=repo_data.default_branch 1279) 1280 1281@repositories.route("/<username>/<repository>/forum/<int:post_id>/voteup", 1282defaults={"score": 1}) 1283@repositories.route("/<username>/<repository>/forum/<int:post_id>/votedown", 1284defaults={"score": -1}) 1285@repositories.route("/<username>/<repository>/forum/<int:post_id>/votes", defaults={"score": 0}) 1286def repository_forum_vote(username, repository, post_id, score): 1287server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1288if not os.path.exists(server_repo_location): 1289flask.abort(404) 1290if not (get_visibility(username, repository) or get_permission_level( 1291flask.session.get("username"), username, 1292repository) is not None): 1293flask.abort(403) 1294 1295if not os.path.exists(server_repo_location): 1296return flask.render_template("errors/not-found.html"), 404 1297 1298repo = git.Repo(server_repo_location) 1299repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1300user = User.query.filter_by(username=flask.session.get("username")).first() 1301relationships = RepoAccess.query.filter_by(repo=repo_data) 1302user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1303if not user: 1304flask.abort(401) 1305 1306post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1307 1308if score: 1309old_relationship = PostVote.query.filter_by(user_username=user.username, 1310post_identifier=post.identifier).first() 1311if old_relationship: 1312if score == old_relationship.vote_score: 1313db.session.delete(old_relationship) 1314post.vote_sum -= old_relationship.vote_score 1315else: 1316post.vote_sum -= old_relationship.vote_score 1317post.vote_sum += score 1318old_relationship.vote_score = score 1319else: 1320relationship = PostVote(user, post, score) 1321post.vote_sum += score 1322db.session.add(relationship) 1323 1324db.session.commit() 1325 1326user_vote = PostVote.query.filter_by(user_username=user.username, 1327post_identifier=post.identifier).first() 1328response = flask.make_response( 1329str(post.vote_sum) + " " + str(user_vote.vote_score if user_vote else 0)) 1330response.content_type = "text/plain" 1331 1332return response 1333 1334 1335@repositories.route("/<username>/<repository>/forum/<int:post_id>/label", methods=["POST"]) 1336def repository_forum_label(username, repository, post_id): 1337server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1338if not os.path.exists(server_repo_location): 1339flask.abort(404) 1340if not get_permission_level(flask.session.get("username"), username, repository): 1341flask.abort(403) 1342 1343repo = git.Repo(server_repo_location) 1344repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1345user = User.query.filter_by(username=flask.session.get("username")).first() 1346relationships = RepoAccess.query.filter_by(repo=repo_data) 1347user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1348 1349post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1350 1351if not post: 1352flask.abort(404) 1353if post.parent: 1354flask.abort(400) 1355 1356label = db.session.get(Label, flask.request.form["label"]) 1357 1358if PostLabel.query.filter_by(post=post, label=label).first(): 1359return flask.redirect( 1360flask.url_for(".repository_forum_thread", username=username, repository=repository, 1361post_id=post_id), 1362code=303) 1363 1364post_label = PostLabel(post, label) 1365db.session.add(post_label) 1366 1367db.session.commit() 1368 1369return flask.redirect( 1370flask.url_for(".repository_forum_thread", username=username, repository=repository, 1371post_id=post_id), 1372code=303) 1373 1374 1375@repositories.route("/<username>/<repository>/forum/<int:post_id>/remove-label") 1376def repository_forum_remove_label(username, repository, post_id): 1377server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1378if not os.path.exists(server_repo_location): 1379flask.abort(404) 1380if not get_permission_level(flask.session.get("username"), username, repository): 1381flask.abort(403) 1382 1383repo = git.Repo(server_repo_location) 1384repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1385user = User.query.filter_by(username=flask.session.get("username")).first() 1386relationships = RepoAccess.query.filter_by(repo=repo_data) 1387user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1388 1389post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1390 1391if not post: 1392flask.abort(404) 1393if post.parent: 1394flask.abort(400) 1395 1396label = db.session.get(Label, flask.request.args["label"]) 1397 1398post_label = PostLabel.query.filter_by(post=post, label=label).first() 1399db.session.delete(post_label) 1400 1401db.session.commit() 1402 1403return flask.redirect( 1404flask.url_for(".repository_forum_thread", username=username, repository=repository, 1405post_id=post_id), 1406code=303) 1407 1408 1409@repositories.route("/<username>/<repository>/favourite") 1410def repository_favourite(username, repository): 1411server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1412if not os.path.exists(server_repo_location): 1413flask.abort(404) 1414if not (get_visibility(username, repository) or get_permission_level( 1415flask.session.get("username"), username, 1416repository) is not None): 1417flask.abort(403) 1418 1419if not os.path.exists(server_repo_location): 1420return flask.render_template("errors/not-found.html"), 404 1421 1422repo = git.Repo(server_repo_location) 1423repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1424user = User.query.filter_by(username=flask.session.get("username")).first() 1425relationships = RepoAccess.query.filter_by(repo=repo_data) 1426user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1427if not user: 1428flask.abort(401) 1429 1430old_relationship = RepoFavourite.query.filter_by(user_username=user.username, 1431repo_route=repo_data.route).first() 1432if old_relationship: 1433db.session.delete(old_relationship) 1434else: 1435relationship = RepoFavourite(user, repo_data) 1436db.session.add(relationship) 1437 1438db.session.commit() 1439 1440return flask.redirect(flask.url_for("favourites"), code=303) 1441 1442 1443@repositories.route("/<username>/<repository>/users/", methods=["GET", "POST"]) 1444def repository_users(username, repository): 1445server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1446if not os.path.exists(server_repo_location): 1447flask.abort(404) 1448if not (get_visibility(username, repository) or get_permission_level( 1449flask.session.get("username"), username, 1450repository) is not None): 1451flask.abort(403) 1452 1453if not os.path.exists(server_repo_location): 1454return flask.render_template("errors/not-found.html"), 404 1455 1456repo = git.Repo(server_repo_location) 1457repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1458user = User.query.filter_by(username=flask.session.get("username")).first() 1459relationships = RepoAccess.query.filter_by(repo=repo_data) 1460user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1461 1462if flask.request.method == "GET": 1463return flask.render_template( 1464"repo-users.html", 1465username=username, 1466repository=repository, 1467repo_data=repo_data, 1468relationships=relationships, 1469repo=repo, 1470user_relationship=user_relationship, 1471remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1472is_favourite=get_favourite(flask.session.get("username"), username, repository) 1473) 1474else: 1475if get_permission_level(flask.session.get("username"), username, repository) != 2: 1476flask.abort(401) 1477 1478if flask.request.form.get("new-username"): 1479# Create new relationship 1480new_user = User.query.filter_by( 1481username=flask.request.form.get("new-username")).first() 1482relationship = RepoAccess(new_user, repo_data, flask.request.form.get("new-level")) 1483db.session.add(relationship) 1484db.session.commit() 1485if flask.request.form.get("update-username"): 1486# Create new relationship 1487updated_user = User.query.filter_by( 1488username=flask.request.form.get("update-username")).first() 1489relationship = RepoAccess.query.filter_by(repo=repo_data, user=updated_user).first() 1490if flask.request.form.get("update-level") == -1: 1491relationship.delete() 1492else: 1493relationship.access_level = flask.request.form.get("update-level") 1494db.session.commit() 1495 1496return flask.redirect( 1497app.url_for(".repository_users", username=username, repository=repository)) 1498 1499 1500@repositories.route("/<username>/<repository>/branches/") 1501def repository_branches(username, repository): 1502server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1503if not os.path.exists(server_repo_location): 1504flask.abort(404) 1505if not (get_visibility(username, repository) or get_permission_level( 1506flask.session.get("username"), username, 1507repository) is not None): 1508flask.abort(403) 1509 1510if not os.path.exists(server_repo_location): 1511return flask.render_template("errors/not-found.html"), 404 1512 1513repo = git.Repo(server_repo_location) 1514repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1515 1516return flask.render_template( 1517"repo-branches.html", 1518username=username, 1519repository=repository, 1520repo_data=repo_data, 1521repo=repo, 1522remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1523is_favourite=get_favourite(flask.session.get("username"), username, repository) 1524) 1525 1526 1527@repositories.route("/<username>/<repository>/log/", defaults={"branch": None}) 1528@repositories.route("/<username>/<repository>/log/<branch>/") 1529def repository_log(username, repository, branch): 1530server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1531if not os.path.exists(server_repo_location): 1532flask.abort(404) 1533if not (get_visibility(username, repository) or get_permission_level( 1534flask.session.get("username"), username, 1535repository) is not None): 1536flask.abort(403) 1537 1538if not os.path.exists(server_repo_location): 1539return flask.render_template("errors/not-found.html"), 404 1540 1541repo = git.Repo(server_repo_location) 1542repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1543if not repo_data.default_branch: 1544if repo.heads: 1545repo_data.default_branch = repo.heads[0].name 1546else: 1547return flask.render_template("empty.html", 1548remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200 1549if not branch: 1550branch = repo_data.default_branch 1551return flask.redirect(f"./{branch}", code=302) 1552 1553if branch.startswith("tag:"): 1554ref = f"tags/{branch[4:]}" 1555elif branch.startswith("~"): 1556ref = branch[1:] 1557else: 1558ref = f"heads/{branch}" 1559 1560ref = ref.replace("~", "/") # encode slashes for URL support 1561 1562try: 1563repo.git.checkout("-f", ref) 1564except git.exc.GitCommandError: 1565return flask.render_template("errors/not-found.html"), 404 1566 1567branches = repo.heads 1568 1569all_refs = [] 1570for ref in repo.heads: 1571all_refs.append((ref, "head")) 1572for ref in repo.tags: 1573all_refs.append((ref, "tag")) 1574 1575commit_list = [f"/{username}/{repository}/{sha}" for sha in 1576git_command(server_repo_location, None, "log", 1577"--format='%H'").decode().split("\n")] 1578 1579commits = Commit.query.filter(Commit.identifier.in_(commit_list)).order_by(Commit.author_date.desc()) 1580page_number = flask.request.args.get("page", 1, type=int) 1581if flask.session.get("username"): 1582default_page_length = db.session.get(User, flask.session.get("username")).default_page_length 1583else: 1584default_page_length = 16 1585page_length = flask.request.args.get("per_page", default_page_length, type=int) 1586page_listing = db.paginate(commits, page=page_number, per_page=page_length) 1587 1588if page_listing.has_next: 1589next_page = page_listing.next_num 1590else: 1591next_page = None 1592 1593if page_listing.has_prev: 1594prev_page = page_listing.prev_num 1595else: 1596prev_page = None 1597 1598return flask.render_template( 1599"repo-log.html", 1600username=username, 1601repository=repository, 1602branches=all_refs, 1603current=branch, 1604repo_data=repo_data, 1605repo=repo, 1606commits=page_listing, 1607remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1608is_favourite=get_favourite(flask.session.get("username"), username, repository), 1609page_number=page_number, 1610page_length=page_length, 1611next_page=next_page, 1612prev_page=prev_page, 1613num_pages=page_listing.pages 1614) 1615 1616 1617@repositories.route("/<username>/<repository>/prs/", methods=["GET", "POST"]) 1618def repository_prs(username, repository): 1619server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1620if not os.path.exists(server_repo_location): 1621flask.abort(404) 1622if not (get_visibility(username, repository) or get_permission_level( 1623flask.session.get("username"), username, 1624repository) is not None): 1625flask.abort(403) 1626 1627if not os.path.exists(server_repo_location): 1628return flask.render_template("errors/not-found.html"), 404 1629 1630if flask.request.method == "GET": 1631repo = git.Repo(server_repo_location) 1632repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1633user = User.query.filter_by(username=flask.session.get("username")).first() 1634 1635return flask.render_template( 1636"repo-prs.html", 1637username=username, 1638repository=repository, 1639repo_data=repo_data, 1640repo=repo, 1641PullRequest=PullRequest, 1642remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1643is_favourite=get_favourite(flask.session.get("username"), username, repository), 1644default_branch=repo_data.default_branch, 1645branches=repo.branches 1646) 1647 1648elif "id" not in flask.request.form: 1649repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1650head = flask.request.form.get("head") 1651head_route = flask.request.form.get("headroute") 1652base = flask.request.form.get("base") 1653 1654if not head and base and head_route: 1655return flask.redirect(".", 400) 1656 1657head_repo = git.Repo(os.path.join(config.REPOS_PATH, head_route.lstrip("/"))) 1658base_repo = git.Repo(server_repo_location) 1659 1660if head not in head_repo.branches or base not in base_repo.branches: 1661flask.flash(Markup( 1662"<iconify-icon icon='mdi:error'></iconify-icon>" + _("Bad branch name")), 1663category="error") 1664return flask.redirect(".", 303) 1665 1666head_data = db.session.get(Repo, head_route) 1667if not head_data.visibility: 1668flask.flash(Markup( 1669"<iconify-icon icon='mdi:error'></iconify-icon>" + _( 1670"Head can't be restricted")), 1671category="error") 1672return flask.redirect(".", 303) 1673 1674pull_request = PullRequest(head_data, head, repo_data, base, 1675db.session.get(User, flask.session["username"])) 1676 1677db.session.add(pull_request) 1678db.session.commit() 1679 1680# Create the notification 1681notification = Notification({"type": "pr", "head": pull_request.head.route, "base": pull_request.base.route, "pr": pull_request.id}) 1682db.session.add(notification) 1683db.session.commit() 1684 1685# Send a notification to all users who have enabled PR notifications for this repo 1686for relationship in RepoFavourite.query.filter_by(repo_route=pull_request.base.route, notify_pr=True).all(): 1687user = relationship.user 1688user_notification = UserNotification(user, notification, 1) 1689db.session.add(user_notification) 1690db.session.commit() 1691celery_tasks.send_notification.apply_async(args=[user_notification.id]) 1692 1693return flask.redirect(".", 303) 1694else: 1695id = flask.request.form.get("id") 1696pull_request = db.session.get(PullRequest, id) 1697 1698if not pull_request: 1699flask.abort(404) 1700 1701if not (get_visibility(username, repository) or get_permission_level( 1702flask.session.get("username"), username, 1703repository) >= 1 or pull_request.owner.username == flask.session.get("username")): 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() 1710 1711if pull_request: 1712pull_request.resolves_list = flask.request.form.get("resolves") 1713db.session.commit() 1714 1715return flask.redirect(".", 303) 1716 1717 1718@repositories.route("/<username>/<repository>/prs/merge", methods=["POST"]) 1719def repository_prs_merge(username, repository): 1720server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1721if not os.path.exists(server_repo_location): 1722flask.abort(404) 1723if not (get_visibility(username, repository) or get_permission_level( 1724flask.session.get("username"), username, 1725repository) is not None): 1726flask.abort(403) 1727 1728if not get_permission_level(flask.session.get("username"), username, repository): 1729flask.abort(401) 1730 1731repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1732repo = git.Repo(server_repo_location) 1733id = flask.request.form.get("id") 1734 1735pull_request = db.session.get(PullRequest, id) 1736 1737if pull_request: 1738result = celery_tasks.merge_heads.delay( 1739pull_request.head_route, 1740pull_request.head_branch, 1741pull_request.base_route, 1742pull_request.base_branch, 1743pull_request.id, 1744simulate=True 1745) 1746task_result = worker.AsyncResult(result.id) 1747 1748return flask.redirect(f"/task/{result.id}?pr-id={id}", 303) # should be 202 Accepted but we must use a redirect 1749# db.session.delete(pull_request) 1750# db.session.commit() 1751else: 1752flask.abort(400) 1753 1754 1755@repositories.route("/<username>/<repository>/prs/<int:id>/merge") 1756def repository_prs_merge_stage_two(username, repository, id): 1757server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1758if not os.path.exists(server_repo_location): 1759flask.abort(404) 1760if not (get_visibility(username, repository) or get_permission_level( 1761flask.session.get("username"), username, 1762repository) is not None): 1763flask.abort(403) 1764 1765if not get_permission_level(flask.session.get("username"), username, repository): 1766flask.abort(401) 1767 1768repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1769repo = git.Repo(server_repo_location) 1770 1771pull_request = db.session.get(PullRequest, id) 1772 1773if pull_request: 1774result = celery_tasks.merge_heads.delay( 1775pull_request.head_route, 1776pull_request.head_branch, 1777pull_request.base_route, 1778pull_request.base_branch, 1779pull_request.id, 1780simulate=False 1781) 1782task_result = worker.AsyncResult(result.id) 1783 1784db.session.commit() 1785 1786return flask.redirect(f"/task/{result.id}?pr-id={id}", 303) 1787# db.session.delete(pull_request) 1788else: 1789flask.abort(400) 1790 1791 1792@app.route("/task/<task_id>") 1793def task_monitor(task_id): 1794task_result = worker.AsyncResult(task_id) 1795 1796if flask.request.args.get("partial"): 1797# htmx partial update 1798return render_block("task-monitor.html", "content", result=task_result, query_string=flask.request.query_string.decode(), delay=1000) 1799 1800# Since most tasks finish rather quickly, the initial delay is faster, so it doesn't wait for too long 1801return flask.render_template("task-monitor.html", result=task_result, query_string=flask.request.query_string.decode(), delay=125) 1802 1803 1804@repositories.route("/<username>/<repository>/prs/delete", methods=["POST"]) 1805def repository_prs_delete(username, repository): 1806server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1807if not os.path.exists(server_repo_location): 1808flask.abort(404) 1809if not (get_visibility(username, repository) or get_permission_level( 1810flask.session.get("username"), username, 1811repository) is not None): 1812flask.abort(403) 1813 1814if not get_permission_level(flask.session.get("username"), username, repository): 1815flask.abort(401) 1816 1817repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1818repo = git.Repo(server_repo_location) 1819id = flask.request.form.get("id") 1820 1821pull_request = db.session.get(PullRequest, id) 1822 1823if pull_request: 1824pull_request.state = 2 1825db.session.commit() 1826 1827return flask.redirect(".", 303) 1828 1829 1830@repositories.route("/<username>/<repository>/settings/") 1831def repository_settings(username, repository): 1832if get_permission_level(flask.session.get("username"), username, repository) != 2: 1833flask.abort(401) 1834 1835repo = git.Repo(os.path.join(config.REPOS_PATH, username, repository)) 1836 1837site_link = Markup(f"<code>http{'s' if config.suggest_https else ''}://{username}.{config.BASE_DOMAIN}/{repository}</code>") 1838primary_site_link = Markup(f"<code>http{'s' if config.suggest_https else ''}://{username}.{config.BASE_DOMAIN}/</code>") 1839 1840return flask.render_template("repo-settings.html", username=username, repository=repository, 1841repo_data=db.session.get(Repo, f"/{username}/{repository}"), 1842branches=[branch.name for branch in repo.branches], 1843site_link=site_link, primary_site_link=primary_site_link, 1844remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1845is_favourite=get_favourite(flask.session.get("username"), username, repository), 1846) 1847 1848 1849@repositories.route("/<username>/<repository>/settings/", methods=["POST"]) 1850def repository_settings_post(username, repository): 1851if get_permission_level(flask.session.get("username"), username, repository) != 2: 1852flask.abort(401) 1853 1854repo = db.session.get(Repo, f"/{username}/{repository}") 1855 1856repo.visibility = flask.request.form.get("visibility", type=int) 1857repo.info = flask.request.form.get("description") 1858repo.default_branch = flask.request.form.get("default_branch") 1859repo.url = flask.request.form.get("url") 1860 1861# Update site settings 1862had_site = repo.has_site 1863old_branch = repo.site_branch 1864if flask.request.form.get("site_branch"): 1865repo.site_branch = flask.request.form.get("site_branch") 1866if flask.request.form.get("primary_site"): 1867if had_site != 2: 1868# Remove primary site from other repos 1869for other_repo in Repo.query.filter_by(owner=repo.owner, has_site=2): 1870other_repo.has_site = 1 # switch it to a regular site 1871flask.flash(Markup( 1872_("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( 1873repository=other_repo.route 1874)), category="warning") 1875repo.has_site = 2 1876else: 1877repo.has_site = 1 1878else: 1879repo.site_branch = None 1880repo.has_site = 0 1881 1882db.session.commit() 1883 1884if not (had_site, old_branch) == (repo.has_site, repo.site_branch): 1885# Deploy the newly activated site 1886result = celery_tasks.copy_site.delay(repo.route) 1887 1888if had_site and not repo.has_site: 1889# Remove the site 1890result = celery_tasks.delete_site.delay(repo.route) 1891 1892if repo.has_site == 2 or (had_site == 2 and had_site != repo.has_site): 1893# Deploy all other sites which were destroyed by the primary site 1894for other_repo in Repo.query.filter_by(owner=repo.owner, has_site=1): 1895result = celery_tasks.copy_site.delay(other_repo.route) 1896 1897return flask.redirect(f"/{username}/{repository}/settings", 303) 1898 1899 1900@repositories.route("/<username>/<repository>/settings/add-label", methods=["POST"]) 1901def repository_settings_add_label(username, repository): 1902if get_permission_level(flask.session.get("username"), username, repository) != 2: 1903flask.abort(401) 1904 1905repo_data = db.session.get(Repo, f"/{username}/{repository}") 1906 1907label = Label(repo_data, flask.request.form.get("label"), flask.request.form.get("colour")) 1908db.session.add(label) 1909db.session.commit() 1910 1911return flask.redirect(f"/{username}/{repository}/settings", 303) 1912 1913 1914@repositories.route("/<username>/<repository>/settings/delete-label", methods=["POST"]) 1915def repository_settings_delete_label(username, repository): 1916if get_permission_level(flask.session.get("username"), username, repository) != 2: 1917flask.abort(401) 1918 1919repo_data = db.session.get(Repo, f"/{username}/{repository}") 1920 1921label = db.session.get(Label, flask.request.form.get("id")) 1922 1923db.session.delete(label) 1924db.session.commit() 1925 1926return flask.redirect(f"/{username}/{repository}/settings", 303) 1927 1928 1929@repositories.route("/<username>/<repository>/settings/edit-label", methods=["POST"]) 1930def repository_settings_edit_label(username, repository): 1931if get_permission_level(flask.session.get("username"), username, repository) != 2: 1932flask.abort(401) 1933 1934repo_data = db.session.get(Repo, f"/{username}/{repository}") 1935 1936label = db.session.get(Label, flask.request.form.get("id")) 1937 1938label.name = flask.request.form.get("label") 1939label.colour_hex = flask.request.form.get("colour") 1940 1941db.session.commit() 1942 1943return flask.redirect(f"/{username}/{repository}/settings", 303) 1944 1945 1946@repositories.route("/<username>/<repository>/settings/delete", methods=["POST"]) 1947def repository_settings_delete(username, repository): 1948if username != flask.session.get("username"): 1949flask.abort(401) 1950 1951repo = db.session.get(Repo, f"/{username}/{repository}") 1952 1953if not repo: 1954flask.abort(404) 1955 1956user = db.session.get(User, flask.session.get("username")) 1957 1958if not bcrypt.check_password_hash(user.password_hashed, flask.request.form.get("password")): 1959flask.flash(_("Incorrect password"), category="error") 1960flask.abort(401) 1961 1962if repo.has_site: 1963celery_tasks.delete_site.delay(repo.route) 1964 1965db.session.delete(repo) 1966db.session.commit() 1967 1968shutil.rmtree(os.path.join(config.REPOS_PATH, username, repository)) 1969 1970return flask.redirect(f"/{username}", 303) 1971 1972 1973@app.errorhandler(404) 1974def e404(error): 1975return flask.render_template("errors/not-found.html"), 404 1976 1977 1978@app.errorhandler(401) 1979def e401(error): 1980return flask.render_template("errors/unauthorised.html"), 401 1981 1982 1983@app.errorhandler(403) 1984def e403(error): 1985return flask.render_template("errors/forbidden.html"), 403 1986 1987 1988@app.errorhandler(418) 1989def e418(error): 1990return flask.render_template("errors/teapot.html"), 418 1991 1992 1993@app.errorhandler(405) 1994def e405(error): 1995return flask.render_template("errors/method-not-allowed.html"), 405 1996 1997 1998@app.errorhandler(500) 1999def e500(error): 2000return flask.render_template("errors/server-error.html"), 500 2001 2002 2003@app.errorhandler(400) 2004def e400(error): 2005return flask.render_template("errors/bad-request.html"), 400 2006 2007 2008@app.errorhandler(410) 2009def e410(error): 2010return flask.render_template("errors/gone.html"), 410 2011 2012 2013@app.errorhandler(415) 2014def e415(error): 2015return flask.render_template("errors/media-type.html"), 415 2016 2017 2018if __name__ == "__main__": 2019app.run(debug=True, port=8080, host="0.0.0.0") 2020 2021app.register_blueprint(repositories) 2022