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