2020-04-29 14:41:17 +02:00
|
|
|
import re
|
2021-01-18 21:28:33 +01:00
|
|
|
import string
|
2020-04-29 19:25:49 +02:00
|
|
|
from sqlalchemy import or_
|
2020-08-19 15:21:34 +02:00
|
|
|
from flask import Blueprint, Response, render_template, request, redirect, url_for, abort
|
2020-07-09 18:29:16 +02:00
|
|
|
from flask import session
|
2020-04-28 20:08:56 +02:00
|
|
|
from hubsrht.decorators import adminrequired
|
2020-03-24 15:26:15 +01:00
|
|
|
from hubsrht.projects import ProjectAccess, get_project
|
2020-04-06 19:21:13 +02:00
|
|
|
from hubsrht.services import git, hg
|
2020-04-28 20:08:56 +02:00
|
|
|
from hubsrht.types import Feature, Event, EventType
|
2020-04-01 19:35:25 +02:00
|
|
|
from hubsrht.types import Project, RepoType, Visibility
|
2020-04-29 19:25:49 +02:00
|
|
|
from hubsrht.types import SourceRepo, MailingList, Tracker
|
2020-08-19 15:21:34 +02:00
|
|
|
from srht.config import cfg, get_origin
|
2020-03-24 15:26:15 +01:00
|
|
|
from srht.database import db
|
2020-08-19 15:21:34 +02:00
|
|
|
from srht.flask import csrf_bypass, paginate_query
|
2020-03-24 15:26:15 +01:00
|
|
|
from srht.oauth import current_user, loginrequired
|
2020-04-02 18:55:05 +02:00
|
|
|
from srht.validation import Validation, valid_url
|
2020-03-24 15:26:15 +01:00
|
|
|
|
|
|
|
projects = Blueprint("projects", __name__)
|
|
|
|
|
2020-08-19 15:21:34 +02:00
|
|
|
site_name = cfg("sr.ht", "site-name")
|
|
|
|
ext_origin = get_origin("hub.sr.ht", external=True)
|
2021-01-24 20:47:51 +01:00
|
|
|
|
|
|
|
def get_clone_message(owner, project, scm, sources):
|
|
|
|
repo_urls = ""
|
|
|
|
for repo in sources:
|
|
|
|
# Python doesn't allow \ in format string blocks ({}) so we add
|
|
|
|
# it here to get a newline before URLs block.
|
|
|
|
repo_urls += f"\n {repo.url()}{' - ' + repo.description if repo.description else ''}"
|
|
|
|
|
|
|
|
return f"""
|
2020-08-19 15:21:34 +02:00
|
|
|
|
|
|
|
You have tried to clone a project from {site_name}, but you probably meant to
|
2020-08-20 17:18:39 +02:00
|
|
|
clone a specific {scm} repository for this project instead. A single project on
|
|
|
|
{site_name} often has more than one {scm} repository.
|
2020-08-19 15:21:34 +02:00
|
|
|
|
2021-01-24 20:47:51 +01:00
|
|
|
{"You may want one of the following repositories:" + repo_urls if repo_urls != "" else ""}
|
|
|
|
|
|
|
|
To browse the all of the available repositories for this project, visit this URL:
|
2020-08-19 15:21:34 +02:00
|
|
|
|
|
|
|
{ext_origin}{url_for("sources.sources_GET",
|
|
|
|
owner=owner.canonical_name, project_name=project.name)}
|
|
|
|
"""
|
|
|
|
|
2020-04-29 16:46:05 +02:00
|
|
|
@projects.route("/<owner>/<project_name>/")
|
2020-03-24 17:11:20 +01:00
|
|
|
def summary_GET(owner, project_name):
|
2020-03-24 16:22:33 +01:00
|
|
|
owner, project = get_project(owner, project_name, ProjectAccess.read)
|
|
|
|
|
2020-08-20 17:18:39 +02:00
|
|
|
# Mercurial clone
|
|
|
|
if request.args.get("cmd") == "capabilities":
|
2021-01-24 20:47:51 +01:00
|
|
|
sources = (SourceRepo.query
|
|
|
|
.filter(SourceRepo.project_id == project.id)
|
|
|
|
.filter(SourceRepo.repo_type == RepoType.hg)
|
|
|
|
.filter(SourceRepo.visibility == Visibility.public)
|
|
|
|
.order_by(SourceRepo.updated.desc())
|
|
|
|
.limit(5))
|
|
|
|
|
|
|
|
return Response(get_clone_message(owner, project, "hg", sources),
|
2020-08-20 17:18:39 +02:00
|
|
|
mimetype="text/plain")
|
|
|
|
|
2020-03-24 16:22:33 +01:00
|
|
|
summary = None
|
2020-04-02 00:26:52 +02:00
|
|
|
summary_error = False
|
2020-03-24 16:22:33 +01:00
|
|
|
if project.summary_repo_id is not None:
|
|
|
|
repo = project.summary_repo
|
2020-04-02 00:26:52 +02:00
|
|
|
try:
|
2020-04-06 19:21:13 +02:00
|
|
|
if repo.repo_type == RepoType.git:
|
2020-12-03 14:33:47 +01:00
|
|
|
summary = git.get_readme(owner, repo.remote_id, repo.url())
|
2020-04-06 19:21:13 +02:00
|
|
|
elif repo.repo_type == RepoType.hg:
|
2020-05-23 21:33:32 +02:00
|
|
|
summary = hg.get_readme(owner, repo.name, repo.url())
|
2020-04-06 19:21:13 +02:00
|
|
|
else:
|
|
|
|
assert False
|
2020-04-02 00:26:52 +02:00
|
|
|
except:
|
|
|
|
summary = None
|
|
|
|
summary_error = True
|
2020-03-24 16:22:33 +01:00
|
|
|
|
2020-03-26 00:17:53 +01:00
|
|
|
events = (Event.query
|
|
|
|
.filter(Event.project_id == project.id)
|
2020-04-29 19:25:49 +02:00
|
|
|
.order_by(Event.created.desc()))
|
|
|
|
if not current_user or current_user.id != owner.id:
|
|
|
|
events = (events
|
|
|
|
.outerjoin(SourceRepo)
|
|
|
|
.outerjoin(MailingList)
|
|
|
|
.outerjoin(Tracker)
|
|
|
|
.filter(or_(Event.source_repo == None, SourceRepo.visibility == Visibility.public),
|
|
|
|
or_(Event.mailing_list == None, MailingList.visibility == Visibility.public),
|
|
|
|
or_(Event.tracker == None, Tracker.visibility == Visibility.public)))
|
|
|
|
events = events.limit(2).all()
|
2020-03-26 00:17:53 +01:00
|
|
|
|
2020-03-24 16:22:33 +01:00
|
|
|
return render_template("project-summary.html", view="summary",
|
2020-04-02 00:26:52 +02:00
|
|
|
owner=owner, project=project,
|
|
|
|
summary=summary, summary_error=summary_error,
|
2020-04-01 21:03:12 +02:00
|
|
|
events=events, EventType=EventType)
|
2020-03-24 16:22:33 +01:00
|
|
|
|
2020-08-19 15:21:34 +02:00
|
|
|
@projects.route("/<owner>/<project_name>/info/refs")
|
|
|
|
def summary_refs(owner, project_name):
|
|
|
|
if request.args.get("service") == "git-upload-pack":
|
|
|
|
owner, project = get_project(owner, project_name, ProjectAccess.read)
|
2021-01-24 20:47:51 +01:00
|
|
|
|
|
|
|
sources = (SourceRepo.query
|
|
|
|
.filter(SourceRepo.project_id == project.id)
|
|
|
|
.filter(SourceRepo.repo_type == RepoType.git)
|
|
|
|
.filter(SourceRepo.visibility == Visibility.public)
|
|
|
|
.order_by(SourceRepo.updated.desc())
|
|
|
|
.limit(5))
|
|
|
|
|
|
|
|
msg = get_clone_message(owner, project, "git", sources)
|
2020-08-19 15:21:34 +02:00
|
|
|
|
|
|
|
return Response(f"""001e# service=git-upload-pack
|
|
|
|
000000400000000000000000000000000000000000000000 HEAD\0agent=hubsrht
|
|
|
|
{'{:04x}'.format(4 + 3 + 1 + len(msg))}ERR {msg}0000""",
|
|
|
|
mimetype="application/x-git-upload-pack-advertisement")
|
|
|
|
else:
|
|
|
|
abort(404)
|
|
|
|
|
2020-04-01 21:16:20 +02:00
|
|
|
@projects.route("/<owner>/<project_name>/feed")
|
|
|
|
def feed_GET(owner, project_name):
|
|
|
|
owner, project = get_project(owner, project_name, ProjectAccess.read)
|
|
|
|
|
|
|
|
events = (Event.query
|
|
|
|
.filter(Event.project_id == project.id)
|
|
|
|
.order_by(Event.created.desc()))
|
2020-04-29 19:25:49 +02:00
|
|
|
|
|
|
|
if not current_user or current_user.id != owner.id:
|
|
|
|
events = (events
|
|
|
|
.outerjoin(SourceRepo)
|
|
|
|
.outerjoin(MailingList)
|
|
|
|
.outerjoin(Tracker)
|
|
|
|
.filter(or_(Event.source_repo == None, SourceRepo.visibility == Visibility.public),
|
|
|
|
or_(Event.mailing_list == None, MailingList.visibility == Visibility.public),
|
|
|
|
or_(Event.tracker == None, Tracker.visibility == Visibility.public)))
|
|
|
|
|
2020-04-01 21:16:20 +02:00
|
|
|
events, pagination = paginate_query(events)
|
|
|
|
|
|
|
|
return render_template("project-feed.html",
|
|
|
|
view="summary", owner=owner, project=project,
|
|
|
|
events=events, EventType=EventType, **pagination)
|
|
|
|
|
2020-04-02 17:48:41 +02:00
|
|
|
@projects.route("/<owner>/<project_name>/dismiss-checklist", methods=["POST"])
|
|
|
|
@loginrequired
|
|
|
|
def dismiss_checklist_POST(owner, project_name):
|
|
|
|
owner, project = get_project(owner, project_name, ProjectAccess.write)
|
|
|
|
project.checklist_complete = True
|
|
|
|
db.session.commit()
|
|
|
|
return redirect(url_for("projects.summary_GET",
|
|
|
|
owner=current_user.canonical_name,
|
|
|
|
project_name=project.name))
|
|
|
|
|
2020-09-11 18:00:05 +02:00
|
|
|
def _verify_tags(valid, raw_tags):
|
|
|
|
raw_tags = raw_tags or ""
|
2021-01-18 21:28:33 +01:00
|
|
|
tags = list(filter(lambda t: t,
|
|
|
|
map(lambda t: t.strip(string.whitespace + "#"), raw_tags.split(","))))
|
2020-09-11 18:00:05 +02:00
|
|
|
valid.expect(len(tags) <= 3,
|
|
|
|
f"Too many tags ({len(tags)}, max 3)",
|
|
|
|
field="tags")
|
|
|
|
valid.expect(all(len(t) <= 16 for t in tags),
|
|
|
|
"Tags may be no longer than 16 characters",
|
|
|
|
field="tags")
|
|
|
|
valid.expect(all(re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$", t) for t in tags),
|
|
|
|
"Tags must start with alphanumerics or underscores " +
|
|
|
|
"and may additionally include dots and dashes",
|
|
|
|
field="tags")
|
|
|
|
return tags
|
|
|
|
|
2020-03-24 15:26:15 +01:00
|
|
|
@projects.route("/projects/create")
|
|
|
|
@loginrequired
|
|
|
|
def create_GET():
|
|
|
|
return render_template("project-create.html")
|
|
|
|
|
|
|
|
@projects.route("/projects/create", methods=["POST"])
|
|
|
|
@loginrequired
|
|
|
|
def create_POST():
|
|
|
|
valid = Validation(request)
|
|
|
|
name = valid.require("name")
|
|
|
|
description = valid.require("description")
|
2020-09-12 15:57:41 +02:00
|
|
|
raw_tags = valid.optional("tags")
|
2020-03-24 15:26:15 +01:00
|
|
|
visibility = valid.require("visibility", cls=Visibility)
|
|
|
|
valid.expect(not name or len(name) < 128,
|
|
|
|
"Name must be fewer than 128 characters", field="name")
|
2021-08-08 18:03:52 +02:00
|
|
|
valid.expect(not name or re.match(r'^[A-Za-z0-9._-]+$', name),
|
|
|
|
"Name must match [A-Za-z0-9._-]+", field="name")
|
2021-06-12 17:54:06 +02:00
|
|
|
valid.expect(not name or name not in [".", ".."],
|
|
|
|
"Name cannot be '.' or '..'", field="name")
|
2021-08-08 18:03:52 +02:00
|
|
|
valid.expect(not name or name not in [".git", ".hg"],
|
|
|
|
"Name must not be '.git' or '.hg'", field="name")
|
2020-03-24 15:26:15 +01:00
|
|
|
valid.expect(not name or Project.query
|
2021-08-08 18:03:52 +02:00
|
|
|
.filter(Project.name.ilike(name.replace('_', '\\_')))
|
2020-03-24 15:26:15 +01:00
|
|
|
.filter(Project.owner_id == current_user.id).count() == 0,
|
|
|
|
"Name must be unique among your projects", field="name")
|
|
|
|
valid.expect(not description or len(description) < 512,
|
|
|
|
"Description must be fewer than 512 characters",
|
|
|
|
field="description")
|
2020-09-11 18:00:05 +02:00
|
|
|
tags = _verify_tags(valid, raw_tags)
|
2020-03-24 15:26:15 +01:00
|
|
|
if not valid.ok:
|
2021-01-18 21:28:33 +01:00
|
|
|
kwargs = valid.kwargs
|
|
|
|
kwargs.pop("tags")
|
|
|
|
return render_template("project-create.html", **kwargs, tags=tags)
|
2020-03-24 15:26:15 +01:00
|
|
|
|
|
|
|
project = Project()
|
|
|
|
project.name = name
|
|
|
|
project.description = description
|
2020-09-11 18:00:05 +02:00
|
|
|
project.tags = tags
|
2020-03-24 15:26:15 +01:00
|
|
|
project.visibility = visibility
|
|
|
|
project.owner_id = current_user.id
|
|
|
|
db.session.add(project)
|
|
|
|
db.session.commit()
|
|
|
|
|
2020-03-24 17:11:20 +01:00
|
|
|
return redirect(url_for("projects.summary_GET",
|
2020-03-24 15:26:15 +01:00
|
|
|
owner=current_user.canonical_name,
|
|
|
|
project_name=project.name))
|
2020-04-02 18:55:05 +02:00
|
|
|
|
|
|
|
@projects.route("/<owner>/<project_name>/settings")
|
|
|
|
@loginrequired
|
|
|
|
def config_GET(owner, project_name):
|
|
|
|
owner, project = get_project(owner, project_name, ProjectAccess.write)
|
|
|
|
return render_template("project-config.html", view="add more",
|
|
|
|
owner=owner, project=project)
|
|
|
|
|
|
|
|
@projects.route("/<owner>/<project_name>/settings", methods=["POST"])
|
|
|
|
@loginrequired
|
|
|
|
def config_POST(owner, project_name):
|
|
|
|
owner, project = get_project(owner, project_name, ProjectAccess.write)
|
|
|
|
|
|
|
|
valid = Validation(request)
|
|
|
|
description = valid.require("description")
|
2020-09-12 15:57:41 +02:00
|
|
|
tags = _verify_tags(valid, valid.optional("tags"))
|
2020-04-02 18:55:05 +02:00
|
|
|
website = valid.optional("website")
|
2020-04-30 20:02:08 +02:00
|
|
|
visibility = valid.require("visibility", cls=Visibility)
|
2020-04-02 18:55:05 +02:00
|
|
|
valid.expect(not website or valid_url(website),
|
|
|
|
"Website must be a valid http or https URL")
|
|
|
|
if not valid.ok:
|
|
|
|
return render_template("project-config.html", view="add more",
|
|
|
|
owner=owner, project=project, **valid.kwargs)
|
|
|
|
|
|
|
|
project.description = description
|
2020-09-11 18:00:05 +02:00
|
|
|
project.tags = tags
|
2020-04-02 18:55:05 +02:00
|
|
|
project.website = website
|
2020-04-30 20:02:08 +02:00
|
|
|
project.visibility = visibility
|
2020-04-02 18:55:05 +02:00
|
|
|
db.session.commit()
|
|
|
|
|
|
|
|
return redirect(url_for("projects.summary_GET",
|
|
|
|
owner=current_user.canonical_name,
|
|
|
|
project_name=project.name))
|
2020-04-02 19:14:35 +02:00
|
|
|
|
2020-07-20 16:04:29 +02:00
|
|
|
@projects.route("/<owner>/<project_name>/delete")
|
|
|
|
@loginrequired
|
|
|
|
def delete_GET(owner, project_name):
|
|
|
|
owner, project = get_project(owner, project_name, ProjectAccess.write)
|
|
|
|
return render_template("project-delete.html", view="add more",
|
|
|
|
owner=owner, project=project)
|
|
|
|
|
2020-04-02 19:14:35 +02:00
|
|
|
@projects.route("/<owner>/<project_name>/delete", methods=["POST"])
|
|
|
|
@loginrequired
|
|
|
|
def delete_POST(owner, project_name):
|
|
|
|
owner, project = get_project(owner, project_name, ProjectAccess.write)
|
2020-07-09 15:52:53 +02:00
|
|
|
session["notice"] = f"{project.name} has been deleted."
|
|
|
|
db.engine.execute(f"DELETE FROM project WHERE id = {project.id}")
|
2020-04-02 19:14:35 +02:00
|
|
|
db.session.commit()
|
|
|
|
return redirect(url_for("public.index"))
|
2020-04-28 20:08:56 +02:00
|
|
|
|
|
|
|
@projects.route("/<owner>/<project_name>/feature", methods=["POST"])
|
|
|
|
@adminrequired
|
|
|
|
def feature_POST(owner, project_name):
|
|
|
|
owner, project = get_project(owner, project_name, ProjectAccess.read)
|
|
|
|
valid = Validation(request)
|
|
|
|
|
|
|
|
feature = Feature()
|
|
|
|
feature.project_id = project.id
|
|
|
|
feature.summary = valid.require("summary")
|
|
|
|
if not valid.ok:
|
|
|
|
abort(400) # admin-only route, who cares
|
|
|
|
db.session.add(feature)
|
|
|
|
db.session.commit()
|
|
|
|
return redirect(url_for("public.project_index"))
|