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 941page_number = flask.request.args.get("page", 1, type=int) 942if flask.session.get("username"): 943default_page_length = db.session.get(User, flask.session.get("username")).default_page_length 944else: 945default_page_length = 16 946 947page_length = flask.request.args.get("per_page", default_page_length, type=int) 948 949posts = Post.query.filter_by(repo=repo_data).order_by(Post.last_updated.desc()).paginate( 950page=page_number, per_page=page_length 951) 952 953if posts.has_next: 954next_page = posts.next_num 955else: 956next_page = None 957 958if posts.has_prev: 959prev_page = posts.prev_num 960else: 961prev_page = None 962 963return flask.render_template( 964"repo-forum.html", 965username=username, 966repository=repository, 967repo_data=repo_data, 968relationships=relationships, 969repo=repo, 970user_relationship=user_relationship, 971Post=Post, 972remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 973is_favourite=get_favourite(flask.session.get("username"), username, repository), 974default_branch=repo_data.default_branch, 975page_number=page_number, 976page_length=page_length, 977next_page=next_page, 978prev_page=prev_page, 979num_pages=posts.pages, 980posts=posts 981) 982 983 984@repositories.route("/<username>/<repository>/forum/search") 985def repository_forum_search(username, repository): 986server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 987if not os.path.exists(server_repo_location): 988flask.abort(404) 989if not (get_visibility(username, repository) or get_permission_level( 990flask.session.get("username"), username, 991repository) is not None): 992flask.abort(403) 993 994repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 995user = User.query.filter_by(username=flask.session.get("username")).first() 996relationships = RepoAccess.query.filter_by(repo=repo_data) 997user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 998 999query = flask.request.args.get("q") 1000 1001page_number = flask.request.args.get("page", 1, type=int) 1002if flask.session.get("username"): 1003default_page_length = db.session.get(User, flask.session.get("username")).default_page_length 1004else: 1005default_page_length = 16 1006 1007page_length = flask.request.args.get("per_page", default_page_length, type=int) 1008 1009all_posts = Post.query.filter(Post.repo == repo_data) 1010 1011results = (all_posts 1012.filter(Post.subject.ilike(f"%{query}%") | Post.message.ilike(f"%{query}%")) 1013.order_by(Post.last_updated.desc())) 1014 1015if flask.request.args.get("state"): 1016try: 1017results = results.filter(Post.state == int(flask.request.args.get("state"))) 1018except ValueError: 1019pass # if state is not an integer, ignore it 1020 1021if flask.request.args.get("label"): 1022results = results.filter(Post.labels.any(Label.identifier == flask.request.args.get("label"))) 1023 1024results = results.paginate(page=page_number, per_page=page_length) 1025 1026if results.has_next: 1027next_page = results.next_num 1028else: 1029next_page = None 1030 1031if results.has_prev: 1032prev_page = results.prev_num 1033else: 1034prev_page = None 1035 1036return flask.render_template( 1037"repo-forum-search.html", 1038username=username, 1039repository=repository, 1040repo_data=repo_data, 1041relationships=relationships, 1042user_relationship=user_relationship, 1043query=query, 1044results=results, 1045Post=Post, 1046remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1047is_favourite=get_favourite(flask.session.get("username"), username, repository), 1048default_branch=repo_data.default_branch, 1049page_number=page_number, 1050page_length=page_length, 1051next_page=next_page, 1052prev_page=prev_page, 1053num_pages=results.pages, 1054require_state=flask.request.args.get("state"), 1055require_label=flask.request.args.get("label"), 1056) 1057 1058 1059@repositories.route("/<username>/<repository>/forum/topic/<int:id>") 1060def repository_forum_topic(username, repository, id): 1061server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1062if not os.path.exists(server_repo_location): 1063flask.abort(404) 1064if not (get_visibility(username, repository) or get_permission_level( 1065flask.session.get("username"), username, 1066repository) is not None): 1067flask.abort(403) 1068 1069if not os.path.exists(server_repo_location): 1070return flask.render_template("errors/not-found.html"), 404 1071 1072repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1073user = User.query.filter_by(username=flask.session.get("username")).first() 1074relationships = RepoAccess.query.filter_by(repo=repo_data) 1075user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1076 1077post = Post.query.filter_by(id=id).first() 1078 1079return flask.render_template( 1080"repo-topic.html", 1081username=username, 1082repository=repository, 1083repo_data=repo_data, 1084relationships=relationships, 1085user_relationship=user_relationship, 1086post=post, 1087remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1088is_favourite=get_favourite(flask.session.get("username"), username, repository), 1089default_branch=repo_data.default_branch 1090) 1091 1092 1093@repositories.route("/<username>/<repository>/forum/new", methods=["POST", "GET"]) 1094def repository_forum_new(username, repository): 1095server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1096if not os.path.exists(server_repo_location): 1097flask.abort(404) 1098if not ((flask.session.get("username") and get_visibility(username, repository)) or get_permission_level( 1099flask.session.get("username"), username, 1100repository) is not None): 1101flask.abort(403) 1102 1103if not os.path.exists(server_repo_location): 1104return flask.render_template("errors/not-found.html"), 404 1105 1106repo = git.Repo(server_repo_location) 1107repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1108user = User.query.filter_by(username=flask.session.get("username")).first() 1109relationships = RepoAccess.query.filter_by(repo=repo_data) 1110user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1111 1112post = Post(user, repo_data, None, flask.request.form["subject"], 1113flask.request.form["message"]) 1114 1115db.session.add(post) 1116db.session.commit() 1117 1118return flask.redirect( 1119flask.url_for(".repository_forum_thread", username=username, repository=repository, 1120post_id=post.number), 1121code=303) 1122 1123 1124@repositories.route("/<username>/<repository>/forum/<int:post_id>") 1125def repository_forum_thread(username, repository, post_id): 1126server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1127if not os.path.exists(server_repo_location): 1128flask.abort(404) 1129if not (get_visibility(username, repository) or get_permission_level( 1130flask.session.get("username"), username, 1131repository) is not None): 1132flask.abort(403) 1133 1134if not os.path.exists(server_repo_location): 1135return flask.render_template("errors/not-found.html"), 404 1136 1137repo = git.Repo(server_repo_location) 1138repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1139user = User.query.filter_by(username=flask.session.get("username")).first() 1140relationships = RepoAccess.query.filter_by(repo=repo_data) 1141user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1142 1143if user: 1144max_post_nesting = user.max_post_nesting 1145else: 1146max_post_nesting = 2 1147 1148return flask.render_template( 1149"repo-forum-thread.html", 1150username=username, 1151repository=repository, 1152repo_data=repo_data, 1153relationships=relationships, 1154repo=repo, 1155Post=Post, 1156user_relationship=user_relationship, 1157post_id=post_id, 1158max_post_nesting=max_post_nesting, 1159remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1160is_favourite=get_favourite(flask.session.get("username"), username, repository), 1161parent=Post.query.filter_by(repo=repo_data, number=post_id).first(), 1162has_permission=not ((not get_permission_level(flask.session.get("username"), username, 1163repository)) and db.session.get(Post, 1164f"/{username}/{repository}/{post_id}").owner.username != flask.session.get("username")), 1165) 1166 1167 1168@repositories.route("/<username>/<repository>/forum/<int:post_id>/change-state", 1169methods=["POST"]) 1170def repository_forum_change_state(username, repository, post_id): 1171server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1172if not os.path.exists(server_repo_location): 1173flask.abort(404) 1174if (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"): 1175flask.abort(403) 1176 1177repo = git.Repo(server_repo_location) 1178repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1179user = User.query.filter_by(username=flask.session.get("username")).first() 1180relationships = RepoAccess.query.filter_by(repo=repo_data) 1181user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1182 1183post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1184 1185if not post: 1186flask.abort(404) 1187if post.parent: 1188flask.abort(400) 1189 1190post.state = int(flask.request.form["new-state"]) 1191 1192db.session.commit() 1193 1194return flask.redirect( 1195flask.url_for(".repository_forum_thread", username=username, repository=repository, 1196post_id=post_id), 1197code=303) 1198 1199 1200@repositories.route("/<username>/<repository>/forum/<int:post_id>/reply", methods=["POST"]) 1201def repository_forum_reply(username, repository, post_id): 1202server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1203if not os.path.exists(server_repo_location): 1204flask.abort(404) 1205if not ((flask.session.get("username") and get_visibility(username, repository)) or get_permission_level( 1206flask.session.get("username"), username, 1207repository) is not None): 1208flask.abort(403) 1209 1210if not os.path.exists(server_repo_location): 1211return flask.render_template("errors/not-found.html"), 404 1212 1213repo = git.Repo(server_repo_location) 1214repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1215user = User.query.filter_by(username=flask.session.get("username")).first() 1216relationships = RepoAccess.query.filter_by(repo=repo_data) 1217user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1218if not user: 1219flask.abort(401) 1220 1221parent = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1222post = Post(user, repo_data, parent, flask.request.form["subject"], 1223flask.request.form["message"]) 1224 1225db.session.add(post) 1226post.update_date() 1227db.session.commit() 1228 1229return flask.redirect( 1230flask.url_for(".repository_forum_thread", username=username, repository=repository, 1231post_id=post_id), 1232code=303) 1233 1234 1235@repositories.route("/<username>/<repository>/forum/<int:post_id>/edit", methods=["POST"]) 1236def repository_forum_edit(username, repository, post_id): 1237server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1238if not os.path.exists(server_repo_location): 1239flask.abort(404) 1240if not (get_visibility(username, repository) or get_permission_level( 1241flask.session.get("username"), username, 1242repository) is not None): 1243flask.abort(403) 1244 1245if not os.path.exists(server_repo_location): 1246return flask.render_template("errors/not-found.html"), 404 1247 1248repo = git.Repo(server_repo_location) 1249repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1250user = User.query.filter_by(username=flask.session.get("username")).first() 1251relationships = RepoAccess.query.filter_by(repo=repo_data) 1252user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1253if not user: 1254flask.abort(401) 1255post = db.session.get(Post, f"/{username}/{repository}/{post_id}") 1256if user != post.owner: 1257flask.abort(403) 1258 1259post.subject = flask.request.form["subject"] 1260post.message = flask.request.form["message"] 1261post.html = markdown.markdown2html(post.message).prettify() 1262post.update_date() 1263db.session.commit() 1264 1265return flask.redirect( 1266flask.url_for(".repository_forum_thread", username=username, repository=repository, 1267post_id=post_id), 1268code=303) 1269 1270 1271@repositories.route("/<username>/<repository>/forum/<int:post_id>/edit", methods=["GET"]) 1272def repository_forum_edit_form(username, repository, post_id): 1273server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1274if not os.path.exists(server_repo_location): 1275flask.abort(404) 1276if not (get_visibility(username, repository) or get_permission_level( 1277flask.session.get("username"), username, 1278repository) is not None): 1279flask.abort(403) 1280 1281if not os.path.exists(server_repo_location): 1282return flask.render_template("errors/not-found.html"), 404 1283 1284repo = git.Repo(server_repo_location) 1285repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1286user = User.query.filter_by(username=flask.session.get("username")).first() 1287relationships = RepoAccess.query.filter_by(repo=repo_data) 1288user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1289if not user: 1290flask.abort(401) 1291post = db.session.get(Post, f"/{username}/{repository}/{post_id}") 1292if user != post.owner: 1293flask.abort(403) 1294 1295return flask.render_template( 1296"repo-forum-edit.html", 1297username=username, 1298repository=repository, 1299repo_data=repo_data, 1300relationships=relationships, 1301repo=repo, 1302user_relationship=user_relationship, 1303post=post, 1304remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1305is_favourite=get_favourite(flask.session.get("username"), username, repository), 1306default_branch=repo_data.default_branch 1307) 1308 1309@repositories.route("/<username>/<repository>/forum/<int:post_id>/voteup", 1310defaults={"score": 1}) 1311@repositories.route("/<username>/<repository>/forum/<int:post_id>/votedown", 1312defaults={"score": -1}) 1313@repositories.route("/<username>/<repository>/forum/<int:post_id>/votes", defaults={"score": 0}) 1314def repository_forum_vote(username, repository, post_id, score): 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_visibility(username, repository) or get_permission_level( 1319flask.session.get("username"), username, 1320repository) is not None): 1321flask.abort(403) 1322 1323if not os.path.exists(server_repo_location): 1324return flask.render_template("errors/not-found.html"), 404 1325 1326repo = git.Repo(server_repo_location) 1327repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1328user = User.query.filter_by(username=flask.session.get("username")).first() 1329relationships = RepoAccess.query.filter_by(repo=repo_data) 1330user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1331if not user: 1332flask.abort(401) 1333 1334post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1335 1336if score: 1337old_relationship = PostVote.query.filter_by(user_username=user.username, 1338post_identifier=post.identifier).first() 1339if old_relationship: 1340if score == old_relationship.vote_score: 1341db.session.delete(old_relationship) 1342post.vote_sum -= old_relationship.vote_score 1343else: 1344post.vote_sum -= old_relationship.vote_score 1345post.vote_sum += score 1346old_relationship.vote_score = score 1347else: 1348relationship = PostVote(user, post, score) 1349post.vote_sum += score 1350db.session.add(relationship) 1351 1352db.session.commit() 1353 1354user_vote = PostVote.query.filter_by(user_username=user.username, 1355post_identifier=post.identifier).first() 1356response = flask.make_response( 1357str(post.vote_sum) + " " + str(user_vote.vote_score if user_vote else 0)) 1358response.content_type = "text/plain" 1359 1360return response 1361 1362 1363@repositories.route("/<username>/<repository>/forum/<int:post_id>/label", methods=["POST"]) 1364def repository_forum_label(username, repository, post_id): 1365server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1366if not os.path.exists(server_repo_location): 1367flask.abort(404) 1368if not get_permission_level(flask.session.get("username"), username, repository): 1369flask.abort(403) 1370 1371repo = git.Repo(server_repo_location) 1372repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1373user = User.query.filter_by(username=flask.session.get("username")).first() 1374relationships = RepoAccess.query.filter_by(repo=repo_data) 1375user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1376 1377post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1378 1379if not post: 1380flask.abort(404) 1381if post.parent: 1382flask.abort(400) 1383 1384label = db.session.get(Label, flask.request.form["label"]) 1385 1386if PostLabel.query.filter_by(post=post, label=label).first(): 1387return flask.redirect( 1388flask.url_for(".repository_forum_thread", username=username, repository=repository, 1389post_id=post_id), 1390code=303) 1391 1392post_label = PostLabel(post, label) 1393db.session.add(post_label) 1394 1395db.session.commit() 1396 1397return flask.redirect( 1398flask.url_for(".repository_forum_thread", username=username, repository=repository, 1399post_id=post_id), 1400code=303) 1401 1402 1403@repositories.route("/<username>/<repository>/forum/<int:post_id>/remove-label") 1404def repository_forum_remove_label(username, repository, post_id): 1405server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1406if not os.path.exists(server_repo_location): 1407flask.abort(404) 1408if not get_permission_level(flask.session.get("username"), username, repository): 1409flask.abort(403) 1410 1411repo = git.Repo(server_repo_location) 1412repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1413user = User.query.filter_by(username=flask.session.get("username")).first() 1414relationships = RepoAccess.query.filter_by(repo=repo_data) 1415user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1416 1417post = Post.query.filter_by(identifier=f"/{username}/{repository}/{post_id}").first() 1418 1419if not post: 1420flask.abort(404) 1421if post.parent: 1422flask.abort(400) 1423 1424label = db.session.get(Label, flask.request.args["label"]) 1425 1426post_label = PostLabel.query.filter_by(post=post, label=label).first() 1427db.session.delete(post_label) 1428 1429db.session.commit() 1430 1431return flask.redirect( 1432flask.url_for(".repository_forum_thread", username=username, repository=repository, 1433post_id=post_id), 1434code=303) 1435 1436 1437@repositories.route("/<username>/<repository>/favourite") 1438def repository_favourite(username, repository): 1439server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1440if not os.path.exists(server_repo_location): 1441flask.abort(404) 1442if not (get_visibility(username, repository) or get_permission_level( 1443flask.session.get("username"), username, 1444repository) is not None): 1445flask.abort(403) 1446 1447if not os.path.exists(server_repo_location): 1448return flask.render_template("errors/not-found.html"), 404 1449 1450repo = git.Repo(server_repo_location) 1451repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1452user = User.query.filter_by(username=flask.session.get("username")).first() 1453relationships = RepoAccess.query.filter_by(repo=repo_data) 1454user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1455if not user: 1456flask.abort(401) 1457 1458old_relationship = RepoFavourite.query.filter_by(user_username=user.username, 1459repo_route=repo_data.route).first() 1460if old_relationship: 1461db.session.delete(old_relationship) 1462else: 1463relationship = RepoFavourite(user, repo_data) 1464db.session.add(relationship) 1465 1466db.session.commit() 1467 1468return flask.redirect(flask.url_for("favourites"), code=303) 1469 1470 1471@repositories.route("/<username>/<repository>/users/", methods=["GET", "POST"]) 1472def repository_users(username, repository): 1473server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1474if not os.path.exists(server_repo_location): 1475flask.abort(404) 1476if not (get_visibility(username, repository) or get_permission_level( 1477flask.session.get("username"), username, 1478repository) is not None): 1479flask.abort(403) 1480 1481if not os.path.exists(server_repo_location): 1482return flask.render_template("errors/not-found.html"), 404 1483 1484repo = git.Repo(server_repo_location) 1485repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1486user = User.query.filter_by(username=flask.session.get("username")).first() 1487relationships = RepoAccess.query.filter_by(repo=repo_data) 1488user_relationship = RepoAccess.query.filter_by(repo=repo_data, user=user).first() 1489 1490if flask.request.method == "GET": 1491return flask.render_template( 1492"repo-users.html", 1493username=username, 1494repository=repository, 1495repo_data=repo_data, 1496relationships=relationships, 1497repo=repo, 1498user_relationship=user_relationship, 1499remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1500is_favourite=get_favourite(flask.session.get("username"), username, repository) 1501) 1502else: 1503if get_permission_level(flask.session.get("username"), username, repository) != 2: 1504flask.abort(401) 1505 1506if flask.request.form.get("new-username"): 1507# Create new relationship 1508new_user = User.query.filter_by( 1509username=flask.request.form.get("new-username")).first() 1510relationship = RepoAccess(new_user, repo_data, flask.request.form.get("new-level")) 1511db.session.add(relationship) 1512db.session.commit() 1513if flask.request.form.get("update-username"): 1514# Create new relationship 1515updated_user = User.query.filter_by( 1516username=flask.request.form.get("update-username")).first() 1517relationship = RepoAccess.query.filter_by(repo=repo_data, user=updated_user).first() 1518if flask.request.form.get("update-level") == -1: 1519relationship.delete() 1520else: 1521relationship.access_level = flask.request.form.get("update-level") 1522db.session.commit() 1523 1524return flask.redirect( 1525app.url_for(".repository_users", username=username, repository=repository)) 1526 1527 1528@repositories.route("/<username>/<repository>/branches/") 1529def repository_branches(username, repository): 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() 1543 1544return flask.render_template( 1545"repo-branches.html", 1546username=username, 1547repository=repository, 1548repo_data=repo_data, 1549repo=repo, 1550remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1551is_favourite=get_favourite(flask.session.get("username"), username, repository) 1552) 1553 1554 1555@repositories.route("/<username>/<repository>/log/", defaults={"branch": None}) 1556@repositories.route("/<username>/<repository>/log/<branch>/") 1557def repository_log(username, repository, branch): 1558server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1559if not os.path.exists(server_repo_location): 1560flask.abort(404) 1561if not (get_visibility(username, repository) or get_permission_level( 1562flask.session.get("username"), username, 1563repository) is not None): 1564flask.abort(403) 1565 1566if not os.path.exists(server_repo_location): 1567return flask.render_template("errors/not-found.html"), 404 1568 1569repo = git.Repo(server_repo_location) 1570repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1571if not repo_data.default_branch: 1572if repo.heads: 1573repo_data.default_branch = repo.heads[0].name 1574else: 1575return flask.render_template("empty.html", 1576remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200 1577if not branch: 1578branch = repo_data.default_branch 1579return flask.redirect(f"./{branch}", code=302) 1580 1581if branch.startswith("tag:"): 1582ref = f"tags/{branch[4:]}" 1583elif branch.startswith("~"): 1584ref = branch[1:] 1585else: 1586ref = f"heads/{branch}" 1587 1588ref = ref.replace("~", "/") # encode slashes for URL support 1589 1590try: 1591repo.git.checkout("-f", ref) 1592except git.exc.GitCommandError: 1593return flask.render_template("errors/not-found.html"), 404 1594 1595branches = repo.heads 1596 1597all_refs = [] 1598for ref in repo.heads: 1599all_refs.append((ref, "head")) 1600for ref in repo.tags: 1601all_refs.append((ref, "tag")) 1602 1603commit_list = [f"/{username}/{repository}/{sha}" for sha in 1604git_command(server_repo_location, None, "log", 1605"--format='%H'").decode().split("\n")] 1606 1607commits = Commit.query.filter(Commit.identifier.in_(commit_list)).order_by(Commit.author_date.desc()) 1608page_number = flask.request.args.get("page", 1, type=int) 1609if flask.session.get("username"): 1610default_page_length = db.session.get(User, flask.session.get("username")).default_page_length 1611else: 1612default_page_length = 16 1613page_length = flask.request.args.get("per_page", default_page_length, type=int) 1614page_listing = db.paginate(commits, page=page_number, per_page=page_length) 1615 1616if page_listing.has_next: 1617next_page = page_listing.next_num 1618else: 1619next_page = None 1620 1621if page_listing.has_prev: 1622prev_page = page_listing.prev_num 1623else: 1624prev_page = None 1625 1626return flask.render_template( 1627"repo-log.html", 1628username=username, 1629repository=repository, 1630branches=all_refs, 1631current=branch, 1632repo_data=repo_data, 1633repo=repo, 1634commits=page_listing, 1635remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1636is_favourite=get_favourite(flask.session.get("username"), username, repository), 1637page_number=page_number, 1638page_length=page_length, 1639next_page=next_page, 1640prev_page=prev_page, 1641num_pages=page_listing.pages 1642) 1643 1644 1645@repositories.route("/<username>/<repository>/prs/", methods=["GET", "POST"]) 1646def repository_prs(username, repository): 1647server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1648if not os.path.exists(server_repo_location): 1649flask.abort(404) 1650if not (get_visibility(username, repository) or get_permission_level( 1651flask.session.get("username"), username, 1652repository) is not None): 1653flask.abort(403) 1654 1655if not os.path.exists(server_repo_location): 1656return flask.render_template("errors/not-found.html"), 404 1657 1658if flask.request.method == "GET": 1659repo = git.Repo(server_repo_location) 1660repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1661user = User.query.filter_by(username=flask.session.get("username")).first() 1662 1663return flask.render_template( 1664"repo-prs.html", 1665username=username, 1666repository=repository, 1667repo_data=repo_data, 1668repo=repo, 1669PullRequest=PullRequest, 1670remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1671is_favourite=get_favourite(flask.session.get("username"), username, repository), 1672default_branch=repo_data.default_branch, 1673branches=repo.branches 1674) 1675 1676elif "id" not in flask.request.form: 1677repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1678head = flask.request.form.get("head") 1679head_route = flask.request.form.get("headroute") 1680base = flask.request.form.get("base") 1681 1682if not head and base and head_route: 1683return flask.redirect(".", 400) 1684 1685head_repo = git.Repo(os.path.join(config.REPOS_PATH, head_route.lstrip("/"))) 1686base_repo = git.Repo(server_repo_location) 1687 1688if head not in head_repo.branches or base not in base_repo.branches: 1689flask.flash(Markup( 1690"<iconify-icon icon='mdi:error'></iconify-icon>" + _("Bad branch name")), 1691category="error") 1692return flask.redirect(".", 303) 1693 1694head_data = db.session.get(Repo, head_route) 1695if not head_data.visibility: 1696flask.flash(Markup( 1697"<iconify-icon icon='mdi:error'></iconify-icon>" + _( 1698"Head can't be restricted")), 1699category="error") 1700return flask.redirect(".", 303) 1701 1702pull_request = PullRequest(head_data, head, repo_data, base, 1703db.session.get(User, flask.session["username"])) 1704 1705db.session.add(pull_request) 1706db.session.commit() 1707 1708# Create the notification 1709notification = Notification({"type": "pr", "head": pull_request.head.route, "base": pull_request.base.route, "pr": pull_request.id}) 1710db.session.add(notification) 1711db.session.commit() 1712 1713# Send a notification to all users who have enabled PR notifications for this repo 1714for relationship in RepoFavourite.query.filter_by(repo_route=pull_request.base.route, notify_pr=True).all(): 1715user = relationship.user 1716user_notification = UserNotification(user, notification, 1) 1717db.session.add(user_notification) 1718db.session.commit() 1719celery_tasks.send_notification.apply_async(args=[user_notification.id]) 1720 1721return flask.redirect(".", 303) 1722else: 1723id = flask.request.form.get("id") 1724pull_request = db.session.get(PullRequest, id) 1725 1726if not pull_request: 1727flask.abort(404) 1728 1729if not (get_visibility(username, repository) or get_permission_level( 1730flask.session.get("username"), username, 1731repository) >= 1 or pull_request.owner.username == flask.session.get("username")): 1732flask.abort(403) 1733 1734if not get_permission_level(flask.session.get("username"), username, repository): 1735flask.abort(401) 1736 1737repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1738 1739if pull_request: 1740pull_request.resolves_list = flask.request.form.get("resolves") 1741db.session.commit() 1742 1743return flask.redirect(".", 303) 1744 1745 1746@repositories.route("/<username>/<repository>/prs/merge", methods=["POST"]) 1747def repository_prs_merge(username, repository): 1748server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1749if not os.path.exists(server_repo_location): 1750flask.abort(404) 1751if not (get_visibility(username, repository) or get_permission_level( 1752flask.session.get("username"), username, 1753repository) is not None): 1754flask.abort(403) 1755 1756if not get_permission_level(flask.session.get("username"), username, repository): 1757flask.abort(401) 1758 1759repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1760repo = git.Repo(server_repo_location) 1761id = flask.request.form.get("id") 1762 1763pull_request = db.session.get(PullRequest, id) 1764 1765if pull_request: 1766result = celery_tasks.merge_heads.delay( 1767pull_request.head_route, 1768pull_request.head_branch, 1769pull_request.base_route, 1770pull_request.base_branch, 1771pull_request.id, 1772simulate=True 1773) 1774task_result = worker.AsyncResult(result.id) 1775 1776return flask.redirect(f"/task/{result.id}?pr-id={id}", 303) # should be 202 Accepted but we must use a redirect 1777# db.session.delete(pull_request) 1778# db.session.commit() 1779else: 1780flask.abort(400) 1781 1782 1783@repositories.route("/<username>/<repository>/prs/<int:id>/merge") 1784def repository_prs_merge_stage_two(username, repository, id): 1785server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1786if not os.path.exists(server_repo_location): 1787flask.abort(404) 1788if not (get_visibility(username, repository) or get_permission_level( 1789flask.session.get("username"), username, 1790repository) is not None): 1791flask.abort(403) 1792 1793if not get_permission_level(flask.session.get("username"), username, repository): 1794flask.abort(401) 1795 1796repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1797repo = git.Repo(server_repo_location) 1798 1799pull_request = db.session.get(PullRequest, id) 1800 1801if pull_request: 1802result = celery_tasks.merge_heads.delay( 1803pull_request.head_route, 1804pull_request.head_branch, 1805pull_request.base_route, 1806pull_request.base_branch, 1807pull_request.id, 1808simulate=False 1809) 1810task_result = worker.AsyncResult(result.id) 1811 1812db.session.commit() 1813 1814return flask.redirect(f"/task/{result.id}?pr-id={id}", 303) 1815# db.session.delete(pull_request) 1816else: 1817flask.abort(400) 1818 1819 1820@app.route("/task/<task_id>") 1821def task_monitor(task_id): 1822task_result = worker.AsyncResult(task_id) 1823 1824if flask.request.args.get("partial"): 1825# htmx partial update 1826return render_block("task-monitor.html", "content", result=task_result, query_string=flask.request.query_string.decode(), delay=1000) 1827 1828# Since most tasks finish rather quickly, the initial delay is faster, so it doesn't wait for too long 1829return flask.render_template("task-monitor.html", result=task_result, query_string=flask.request.query_string.decode(), delay=125) 1830 1831 1832@repositories.route("/<username>/<repository>/prs/delete", methods=["POST"]) 1833def repository_prs_delete(username, repository): 1834server_repo_location = os.path.join(config.REPOS_PATH, username, repository) 1835if not os.path.exists(server_repo_location): 1836flask.abort(404) 1837if not (get_visibility(username, repository) or get_permission_level( 1838flask.session.get("username"), username, 1839repository) is not None): 1840flask.abort(403) 1841 1842if not get_permission_level(flask.session.get("username"), username, repository): 1843flask.abort(401) 1844 1845repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 1846repo = git.Repo(server_repo_location) 1847id = flask.request.form.get("id") 1848 1849pull_request = db.session.get(PullRequest, id) 1850 1851if pull_request: 1852pull_request.state = 2 1853db.session.commit() 1854 1855return flask.redirect(".", 303) 1856 1857 1858@repositories.route("/<username>/<repository>/settings/") 1859def repository_settings(username, repository): 1860if get_permission_level(flask.session.get("username"), username, repository) != 2: 1861flask.abort(401) 1862 1863repo = git.Repo(os.path.join(config.REPOS_PATH, username, repository)) 1864 1865site_link = Markup(f"<code>http{'s' if config.suggest_https else ''}://{username}.{config.BASE_DOMAIN}/{repository}</code>") 1866primary_site_link = Markup(f"<code>http{'s' if config.suggest_https else ''}://{username}.{config.BASE_DOMAIN}/</code>") 1867 1868return flask.render_template("repo-settings.html", username=username, repository=repository, 1869repo_data=db.session.get(Repo, f"/{username}/{repository}"), 1870branches=[branch.name for branch in repo.branches], 1871site_link=site_link, primary_site_link=primary_site_link, 1872remote=f"http{'s' if config.suggest_https else ''}://{config.BASE_DOMAIN}/git/{username}/{repository}", 1873is_favourite=get_favourite(flask.session.get("username"), username, repository), 1874) 1875 1876 1877@repositories.route("/<username>/<repository>/settings/", methods=["POST"]) 1878def repository_settings_post(username, repository): 1879if get_permission_level(flask.session.get("username"), username, repository) != 2: 1880flask.abort(401) 1881 1882repo = db.session.get(Repo, f"/{username}/{repository}") 1883 1884repo.visibility = flask.request.form.get("visibility", type=int) 1885repo.info = flask.request.form.get("description") 1886repo.default_branch = flask.request.form.get("default_branch") 1887repo.url = flask.request.form.get("url") 1888 1889# Update site settings 1890had_site = repo.has_site 1891old_branch = repo.site_branch 1892if flask.request.form.get("site_branch"): 1893repo.site_branch = flask.request.form.get("site_branch") 1894if flask.request.form.get("primary_site"): 1895if had_site != 2: 1896# Remove primary site from other repos 1897for other_repo in Repo.query.filter_by(owner=repo.owner, has_site=2): 1898other_repo.has_site = 1 # switch it to a regular site 1899flask.flash(Markup( 1900_("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( 1901repository=other_repo.route 1902)), category="warning") 1903repo.has_site = 2 1904else: 1905repo.has_site = 1 1906else: 1907repo.site_branch = None 1908repo.has_site = 0 1909 1910db.session.commit() 1911 1912if not (had_site, old_branch) == (repo.has_site, repo.site_branch): 1913# Deploy the newly activated site 1914result = celery_tasks.copy_site.delay(repo.route) 1915 1916if had_site and not repo.has_site: 1917# Remove the site 1918result = celery_tasks.delete_site.delay(repo.route) 1919 1920if repo.has_site == 2 or (had_site == 2 and had_site != repo.has_site): 1921# Deploy all other sites which were destroyed by the primary site 1922for other_repo in Repo.query.filter_by(owner=repo.owner, has_site=1): 1923result = celery_tasks.copy_site.delay(other_repo.route) 1924 1925return flask.redirect(f"/{username}/{repository}/settings", 303) 1926 1927 1928@repositories.route("/<username>/<repository>/settings/add-label", methods=["POST"]) 1929def repository_settings_add_label(username, repository): 1930if get_permission_level(flask.session.get("username"), username, repository) != 2: 1931flask.abort(401) 1932 1933repo_data = db.session.get(Repo, f"/{username}/{repository}") 1934 1935label = Label(repo_data, flask.request.form.get("label"), flask.request.form.get("colour")) 1936db.session.add(label) 1937db.session.commit() 1938 1939return flask.redirect(f"/{username}/{repository}/settings", 303) 1940 1941 1942@repositories.route("/<username>/<repository>/settings/delete-label", methods=["POST"]) 1943def repository_settings_delete_label(username, repository): 1944if get_permission_level(flask.session.get("username"), username, repository) != 2: 1945flask.abort(401) 1946 1947repo_data = db.session.get(Repo, f"/{username}/{repository}") 1948 1949label = db.session.get(Label, flask.request.form.get("id")) 1950 1951db.session.delete(label) 1952db.session.commit() 1953 1954return flask.redirect(f"/{username}/{repository}/settings", 303) 1955 1956 1957@repositories.route("/<username>/<repository>/settings/edit-label", methods=["POST"]) 1958def repository_settings_edit_label(username, repository): 1959if get_permission_level(flask.session.get("username"), username, repository) != 2: 1960flask.abort(401) 1961 1962repo_data = db.session.get(Repo, f"/{username}/{repository}") 1963 1964label = db.session.get(Label, flask.request.form.get("id")) 1965 1966label.name = flask.request.form.get("label") 1967label.colour_hex = flask.request.form.get("colour") 1968 1969db.session.commit() 1970 1971return flask.redirect(f"/{username}/{repository}/settings", 303) 1972 1973 1974@repositories.route("/<username>/<repository>/settings/delete", methods=["POST"]) 1975def repository_settings_delete(username, repository): 1976if username != flask.session.get("username"): 1977flask.abort(401) 1978 1979repo = db.session.get(Repo, f"/{username}/{repository}") 1980 1981if not repo: 1982flask.abort(404) 1983 1984user = db.session.get(User, flask.session.get("username")) 1985 1986if not bcrypt.check_password_hash(user.password_hashed, flask.request.form.get("password")): 1987flask.flash(_("Incorrect password"), category="error") 1988flask.abort(401) 1989 1990if repo.has_site: 1991celery_tasks.delete_site.delay(repo.route) 1992 1993db.session.delete(repo) 1994db.session.commit() 1995 1996shutil.rmtree(os.path.join(config.REPOS_PATH, username, repository)) 1997 1998return flask.redirect(f"/{username}", 303) 1999 2000 2001@app.errorhandler(404) 2002def e404(error): 2003return flask.render_template("errors/not-found.html"), 404 2004 2005 2006@app.errorhandler(401) 2007def e401(error): 2008return flask.render_template("errors/unauthorised.html"), 401 2009 2010 2011@app.errorhandler(403) 2012def e403(error): 2013return flask.render_template("errors/forbidden.html"), 403 2014 2015 2016@app.errorhandler(418) 2017def e418(error): 2018return flask.render_template("errors/teapot.html"), 418 2019 2020 2021@app.errorhandler(405) 2022def e405(error): 2023return flask.render_template("errors/method-not-allowed.html"), 405 2024 2025 2026@app.errorhandler(500) 2027def e500(error): 2028return flask.render_template("errors/server-error.html"), 500 2029 2030 2031@app.errorhandler(400) 2032def e400(error): 2033return flask.render_template("errors/bad-request.html"), 400 2034 2035 2036@app.errorhandler(410) 2037def e410(error): 2038return flask.render_template("errors/gone.html"), 410 2039 2040 2041@app.errorhandler(415) 2042def e415(error): 2043return flask.render_template("errors/media-type.html"), 415 2044 2045 2046if __name__ == "__main__": 2047app.run(debug=True, port=8080, host="0.0.0.0") 2048 2049app.register_blueprint(repositories) 2050