~edwargix/git.sr.ht

97248eae33e8a3d921a247b8bf11282c4e884eb8 — Ludovic Chabant 7 years ago 3b46dc0
Replace repo management code with scmsrht lib

- All db types are now using scmsrht mixins
- Delete all code that's already provided by scmsrht
14 files changed, 31 insertions(+), 503 deletions(-)

D gitsrht/access.py
M gitsrht/app.py
D gitsrht/blueprints/manage.py
D gitsrht/blueprints/public.py
M gitsrht/blueprints/repo.py
M gitsrht/blueprints/stats.py
M gitsrht/git.py
D gitsrht/redis.py
M gitsrht/repos.py
M gitsrht/types/__init__.py
D gitsrht/types/access.py
D gitsrht/types/redirect.py
D gitsrht/types/repository.py
M setup.py
D gitsrht/access.py => gitsrht/access.py +0 -85
@@ 1,85 0,0 @@
from flask import abort
from datetime import datetime
from enum import IntFlag
from flask_login import current_user
from gitsrht.types import User, Repository, RepoVisibility, Redirect
from gitsrht.types import Access, AccessMode
from srht.database import db

class UserAccess(IntFlag):
    none = 0
    read = 1
    write = 2
    manage = 4

def check_repo(user, repo, authorized=current_user):
    u = User.query.filter(User.username == user).first()
    if not u:
        abort(404)
    _repo = Repository.query.filter(Repository.owner_id == u.id)\
            .filter(Repository.name == repo).first()
    if not _repo:
        abort(404)
    if _repo.visibility == RepoVisibility.private:
        if not authorized or authorized.id != _repo.owner_id:
            abort(404)
    return u, _repo

def get_repo(owner_name, repo_name):
    if owner_name[0] == "~":
        user = User.query.filter(User.username == owner_name[1:]).first()
        if user:
            repo = Repository.query.filter(Repository.owner_id == user.id)\
                .filter(Repository.name == repo_name).first()
        else:
            repo = None
        if user and not repo:
            repo = (Redirect.query
                    .filter(Redirect.owner_id == user.id)
                    .filter(Redirect.name == repo_name)
                ).first()
        return user, repo
    else:
        # TODO: organizations
        return None, None

def get_access(repo, user=None):
    if not user:
        user = current_user
    if not repo:
        return UserAccess.none
    if isinstance(repo, Redirect):
        # Just pretend they have full access for long enough to do the redirect
        return UserAccess.read | UserAccess.write | UserAccess.manage
    if not user:
        if repo.visibility == RepoVisibility.public or \
                repo.visibility == RepoVisibility.unlisted:
            return UserAccess.read
        return UserAccess.none
    if repo.owner_id == user.id:
        return UserAccess.read | UserAccess.write | UserAccess.manage
    acl = Access.query.filter(Access.repo_id == repo.id).first()
    if acl:
        acl.updated = datetime.utcnow()
        db.session.commit()
        if acl.mode == AccessMode.ro:
            return UserAccess.read
        else:
            return UserAccess.read | UserAccess.write
    if repo.visibility == RepoVisibility.private:
        return UserAccess.none
    return UserAccess.read

def has_access(repo, access, user=None):
    return access in get_access(repo, user)

def check_access(owner_name, repo_name, access):
    owner, repo = get_repo(owner_name, repo_name)
    if not owner or not repo:
        abort(404)
    a = get_access(repo)
    if not UserAccess.write in a:
        abort(404)
    if not access in a:
        abort(403)
    return owner, repo

M gitsrht/app.py => gitsrht/app.py +7 -13
@@ 5,18 5,16 @@ from flask import session
from functools import lru_cache
from gitsrht import urls
from gitsrht.git import commit_time, trim_commit
from gitsrht.types import User, OAuthToken
from gitsrht.repos import GitRepoApi
from gitsrht.types import Access, Redirect, Repository, User, OAuthToken
from scmsrht.flask import ScmSrhtFlask
from srht.config import cfg
from srht.database import DbSession
from srht.flask import SrhtFlask
from srht.oauth import AbstractOAuthService

db = DbSession(cfg("git.sr.ht", "connection-string"))
db.init()

def lookup_user(email):
    return User.query.filter(User.email == email).one_or_none()

client_id = cfg("git.sr.ht", "oauth-client-id")
client_secret = cfg("git.sr.ht", "oauth-client-secret")
builds_client_id = cfg("builds.sr.ht", "oauth-client-id", default=None)


@@ 29,22 27,19 @@ class GitOAuthService(AbstractOAuthService):
                ] if builds_client_id else []),
                token_class=OAuthToken, user_class=User)

class GitApp(SrhtFlask):
class GitApp(ScmSrhtFlask):
    def __init__(self):
        super().__init__("git.sr.ht", __name__,
                access_class=Access, redirect_class=Redirect,
                repository_class=Repository, user_class=User,
                repo_api=GitRepoApi(),
                oauth_service=GitOAuthService())

        self.url_map.strict_slashes = False

        from gitsrht.blueprints.public import public
        from gitsrht.blueprints.repo import repo
        from gitsrht.blueprints.stats import stats
        from gitsrht.blueprints.manage import manage

        self.register_blueprint(public)
        self.register_blueprint(repo)
        self.register_blueprint(stats)
        self.register_blueprint(manage)

        self.add_template_filter(urls.clone_urls)
        self.add_template_filter(urls.log_rss_url)


@@ 59,7 54,6 @@ class GitApp(SrhtFlask):
                "commit_time": commit_time,
                "trim_commit": trim_commit,
                "humanize": humanize,
                "lookup_user": lookup_user,
                "stat": stat,
                "notice": notice,
                "path_join": os.path.join

D gitsrht/blueprints/manage.py => gitsrht/blueprints/manage.py +0 -167
@@ 1,167 0,0 @@
from flask import Blueprint, request, render_template
from flask import redirect, session, url_for
from flask_login import current_user
from srht.config import cfg
from srht.database import db
from srht.flask import loginrequired
from srht.validation import Validation
from gitsrht.types import Repository, RepoVisibility, Redirect
from gitsrht.types import Access, AccessMode, User
from gitsrht.access import check_access, UserAccess
from gitsrht.repos import create_repo, rename_repo, delete_repo
import shutil

manage = Blueprint('manage', __name__)
repos_path = cfg("git.sr.ht", "repos")
post_update = cfg("git.sr.ht", "post-update-script")

@manage.route("/create")
@loginrequired
def index():
    another = request.args.get("another")
    name = request.args.get("name")
    return render_template("create.html", another=another, repo_name=name)

@manage.route("/create", methods=["POST"])
@loginrequired
def create():
    valid = Validation(request)
    repo = create_repo(valid, current_user)
    if not valid.ok:
        return render_template("create.html", **valid.kwargs)
    another = valid.optional("another")
    if another == "on":
        return redirect("/create?another")
    else:
        return redirect("/~{}/{}".format(current_user.username, repo.name))

@manage.route("/<owner_name>/<repo_name>/settings/info")
@loginrequired
def settings_info(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, Redirect):
        return redirect(url_for(".settings_info",
            owner_name=owner_name, repo_name=repo.new_repo.name))
    return render_template("settings_info.html", owner=owner, repo=repo)

@manage.route("/<owner_name>/<repo_name>/settings/info", methods=["POST"])
@loginrequired
def settings_info_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, Redirect):
        repo = repo.new_repo
    valid = Validation(request)
    desc = valid.optional("description", default=repo.description)
    visibility = valid.optional("visibility",
            cls=RepoVisibility,
            default=repo.visibility)
    repo.visibility = visibility
    repo.description = desc
    db.session.commit()
    return redirect("/{}/{}/settings/info".format(owner_name, repo_name))

@manage.route("/<owner_name>/<repo_name>/settings/rename")
@loginrequired
def settings_rename(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, Redirect):
        return redirect(url_for(".settings_rename",
            owner_name=owner_name, repo_name=repo.new_repo.name))
    return render_template("settings_rename.html", owner=owner, repo=repo)

@manage.route("/<owner_name>/<repo_name>/settings/rename", methods=["POST"])
@loginrequired
def settings_rename_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, Redirect):
        repo = repo.new_repo
    valid = Validation(request)
    repo = rename_repo(owner, repo, valid)
    if not repo:
        return render_template("settings_rename.html", owner=owner, repo=repo,
                **valid.kwargs)
    return redirect("/{}/{}".format(owner_name, repo.name))

@manage.route("/<owner_name>/<repo_name>/settings/access")
@loginrequired
def settings_access(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, Redirect):
        return redirect(url_for(".settings_manage",
            owner_name=owner_name, repo_name=repo.new_repo.name))
    return render_template("settings_access.html", owner=owner, repo=repo)

@manage.route("/<owner_name>/<repo_name>/settings/access", methods=["POST"])
@loginrequired
def settings_access_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, Redirect):
        repo = repo.new_repo
    valid = Validation(request)
    user = valid.require("user", friendly_name="User")
    mode = valid.optional("access", cls=AccessMode, default=AccessMode.ro)
    if not valid.ok:
        return render_template("settings_access.html",
                owner=owner, repo=repo, **valid.kwargs)
    # TODO: Group access
    if user[0] == "~":
        user = user[1:]
    user = User.query.filter(User.username == user).first()
    valid.expect(user,
            "I don't know this user. Have they logged into git.sr.ht before?",
            field="user")
    valid.expect(not user or user.id != current_user.id,
            "You can't adjust your own access controls. You always have full read/write access.",
            field="user")
    if not valid.ok:
        return render_template("settings_access.html",
                owner=owner, repo=repo, **valid.kwargs)
    grant = (Access.query
        .filter(Access.repo_id == repo.id, Access.user_id == user.id)
    ).first()
    if not grant:
        grant = Access()
        grant.repo_id = repo.id
        grant.user_id = user.id
        db.session.add(grant)
    grant.mode = mode
    db.session.commit()
    return redirect("/{}/{}/settings/access".format(
        owner.canonical_name, repo.name))

@manage.route("/<owner_name>/<repo_name>/settings/access/revoke/<grant_id>", methods=["POST"])
@loginrequired
def settings_access_revoke_POST(owner_name, repo_name, grant_id):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, Redirect):
        repo = repo.new_repo
    grant = (Access.query
        .filter(Access.repo_id == repo.id, Access.id == grant_id)
    ).first()
    if not grant:
        abort(404)
    db.session.delete(grant)
    db.session.commit()
    return redirect("/{}/{}/settings/access".format(
        owner.canonical_name, repo.name))

@manage.route("/<owner_name>/<repo_name>/settings/delete")
@loginrequired
def settings_delete(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, Redirect):
        return redirect(url_for(".settings_delete",
            owner_name=owner_name, repo_name=repo.new_repo.name))
    return render_template("settings_delete.html", owner=owner, repo=repo)

@manage.route("/<owner_name>/<repo_name>/settings/delete", methods=["POST"])
@loginrequired
def settings_delete_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, Redirect):
        # Normally we'd redirect but we don't want to fuck up some other repo
        abort(404)
    delete_repo(repo)
    session["notice"] = "{}/{} was deleted.".format(
        owner.canonical_name, repo.name)
    return redirect("/" + owner.canonical_name)

D gitsrht/blueprints/public.py => gitsrht/blueprints/public.py +0 -55
@@ 1,55 0,0 @@
from flask import Blueprint, request
from flask import render_template, abort
from flask_login import current_user
import requests
from srht.config import cfg
from srht.flask import paginate_query
from gitsrht.types import User, Repository, RepoVisibility
from sqlalchemy import or_

public = Blueprint('public', __name__)

meta_uri = cfg("meta.sr.ht", "origin")

@public.route("/")
def index():
    if current_user:
        repos = (Repository.query
                .filter(Repository.owner_id == current_user.id)
                .filter(Repository.visibility != RepoVisibility.autocreated)
                .order_by(Repository.updated.desc())
                .limit(10)).all()
    else:
        repos = None
    return render_template("index.html", repos=repos)

@public.route("/~<username>")
@public.route("/~<username>/")
def user_index(username):
    user = User.query.filter(User.username == username).first()
    if not user:
        abort(404)
    search = request.args.get("search")
    repos = Repository.query\
            .filter(Repository.owner_id == user.id)
    if not current_user or current_user.id != user.id:
        # TODO: ACLs
        repos = repos.filter(Repository.visibility == RepoVisibility.public)
    if search:
        repos = repos.filter(or_(
                Repository.name.ilike("%" + search + "%"),
                Repository.description.ilike("%" + search + "%")))
    repos = repos.order_by(Repository.updated.desc())
    repos, pagination = paginate_query(repos)

    r = requests.get(meta_uri + "/api/user/profile", headers={
        "Authorization": "token " + user.oauth_token
    }) # TODO: cache
    if r.status_code == 200:
        profile = r.json()
    else:
        profile = None

    return render_template("user.html",
            user=user, repos=repos, profile=profile,
            search=search, **pagination)

M gitsrht/blueprints/repo.py => gitsrht/blueprints/repo.py +2 -3
@@ 8,17 8,16 @@ from datetime import timedelta
from jinja2 import Markup
from flask import Blueprint, render_template, abort, send_file, request
from flask import Response, url_for
from gitsrht.access import get_repo, has_access, UserAccess
from gitsrht.editorconfig import EditorConfig
from gitsrht.redis import redis
from gitsrht.git import Repository as GitRepository, commit_time, annotate_tree
from gitsrht.git import diffstat
from gitsrht.repos import get_repo_or_redir
from gitsrht.rss import generate_feed
from io import BytesIO
from pygments import highlight
from pygments.lexers import guess_lexer, guess_lexer_for_filename, TextLexer
from pygments.formatters import HtmlFormatter
from scmsrht.access import get_repo, get_repo_or_redir, has_access, UserAccess
from scmsrht.redis import redis
from srht.config import cfg
from srht.markdown import markdown


M gitsrht/blueprints/stats.py => gitsrht/blueprints/stats.py +1 -1
@@ 4,8 4,8 @@ from datetime import date, datetime, timedelta
from flask import Blueprint, render_template
from functools import lru_cache
from gitsrht.git import Repository as GitRepository
from gitsrht.repos import get_repo_or_redir
from gitsrht.types import User
from scmsrht.access import get_repo_or_redir

stats = Blueprint('stats', __name__)


M gitsrht/git.py => gitsrht/git.py +1 -1
@@ 1,8 1,8 @@
from collections import deque
from datetime import datetime, timedelta, timezone
from gitsrht.redis import redis
from pygit2 import Repository as GitRepository, Tag
from jinja2 import Markup, escape
from scmsrht.redis import redis
from stat import filemode
import pygit2
import json

D gitsrht/redis.py => gitsrht/redis.py +0 -6
@@ 1,6 0,0 @@
try:
    from redis import StrictRedis as Redis
except ImportError:
    from redis import Redis

redis = Redis()

M gitsrht/repos.py => gitsrht/repos.py +9 -94
@@ 1,48 1,19 @@
import subprocess
from flask import redirect, abort, url_for, request
from gitsrht.access import get_repo, has_access, UserAccess
from gitsrht.types import User, Repository, RepoVisibility, Redirect
from srht.database import db
from gitsrht.types import Repository, Redirect
from scmsrht.repos import SimpleRepoApi
from srht.config import cfg
import shutil
import re
import os
import os.path

repos_path = cfg("git.sr.ht", "repos")
post_update = cfg("git.sr.ht", "post-update-script")

def validate_name(valid, owner, repo_name):
    if not valid.ok:
        return None
    valid.expect(re.match(r'^[a-z._-][a-z0-9._-]*$', repo_name),
            "Name must match [a-z._-][a-z0-9._-]*", field="name")
    existing = (Repository.query
            .filter(Repository.owner_id == owner.id)
            .filter(Repository.name.ilike(repo_name))
            .first())
    if existing and existing.visibility == RepoVisibility.autocreated:
        return existing
    valid.expect(not existing, "This name is already in use.", field="name")
    return None

def create_repo(valid, owner):
    repo_name = valid.require("name", friendly_name="Name")
    description = valid.optional("description")
    visibility = valid.optional("visibility",
            default="public",
            cls=RepoVisibility)
    repo = validate_name(valid, owner, repo_name)
    if not valid.ok:
        return None

    if not repo:
        repo = Repository()
        repo.name = repo_name
        repo.owner_id = owner.id
        repo.path = os.path.join(repos_path, "~" + owner.username, repo.name)
        db.session.add(repo)
        db.session.flush()
class GitRepoApi(SimpleRepoApi):
    def __init__(self):
        super().__init__(repos_path,
                redirect_class=Redirect,
                repository_class=Repository)

    def do_init_repo(self, owner, repo):
        subprocess.run(["mkdir", "-p", repo.path],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        subprocess.run(["git", "init", "--bare"], cwd=repo.path,


@@ 57,59 28,3 @@ def create_repo(valid, owner):
                post_update,
                os.path.join(repo.path, "hooks", "post-update")
            ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    repo.description = description
    repo.visibility = visibility
    db.session.commit()
    return repo

def rename_repo(owner, repo, valid):
    repo_name = valid.require("name")
    valid.expect(repo.name != repo_name,
            "This is the same name as before.", field="name")
    if not valid.ok:
        return None
    validate_name(valid, owner, repo_name)
    if not valid.ok:
        return None

    _redirect = Redirect()
    _redirect.name = repo.name
    _redirect.path = repo.path
    _redirect.owner_id = repo.owner_id
    _redirect.new_repo_id = repo.id
    db.session.add(_redirect)

    new_path = os.path.join(repos_path, "~" + owner.username, repo_name)

    subprocess.run(["mv", repo.path, new_path])

    repo.path = new_path
    repo.name = repo_name
    db.session.commit()
    return repo

def delete_repo(repo):
    try:
        shutil.rmtree(repo.path)
    except FileNotFoundError:
        pass
    db.session.delete(repo)
    db.session.commit()

def get_repo_or_redir(owner, repo):
    owner, repo = get_repo(owner, repo)
    if not repo:
        abort(404)
    if not has_access(repo, UserAccess.read):
        abort(401)
    if isinstance(repo, Redirect):
        view_args = request.view_args
        if not "repo" in view_args or not "owner" in view_args:
            return redirect(url_for(".summary",
                owner=repo.new_repo.owner.canonical_name,
                repo=repo.new_repo.name))
        view_args["owner"] = repo.new_repo.owner.canonical_name
        view_args["repo"] = repo.new_repo.name
        abort(redirect(url_for(request.endpoint, **view_args)))
    return owner, repo

M gitsrht/types/__init__.py => gitsrht/types/__init__.py +10 -3
@@ 1,5 1,7 @@
from srht.database import Base
from srht.oauth import ExternalUserMixin, ExternalOAuthTokenMixin
from scmsrht.repos import (
        BaseAccessMixin, BaseRedirectMixin, BaseRepositoryMixin)

class User(Base, ExternalUserMixin):
    pass


@@ 7,6 9,11 @@ class User(Base, ExternalUserMixin):
class OAuthToken(Base, ExternalOAuthTokenMixin):
    pass

from .repository import Repository, RepoVisibility
from .redirect import Redirect
from .access import Access, AccessMode
class Access(Base, BaseAccessMixin):
    pass

class Redirect(Base, BaseRedirectMixin):
    pass

class Repository(Base, BaseRepositoryMixin):
    pass

D gitsrht/types/access.py => gitsrht/types/access.py +0 -29
@@ 1,29 0,0 @@
import sqlalchemy as sa
import sqlalchemy_utils as sau
from srht.database import Base
from enum import Enum

class AccessMode(Enum):
    ro = 'ro'
    rw = 'rw'

class Access(Base):
    __tablename__ = 'access'
    id = sa.Column(sa.Integer, primary_key=True)
    created = sa.Column(sa.DateTime, nullable=False)
    updated = sa.Column(sa.DateTime, nullable=False)
    mode = sa.Column(sau.ChoiceType(AccessMode, impl=sa.String()),
            nullable=False, default=AccessMode.ro)

    user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False)
    user = sa.orm.relationship('User', backref='access_grants')

    repo_id = sa.Column(sa.Integer,
            sa.ForeignKey('repository.id', ondelete="CASCADE"),
            nullable=False)
    repo = sa.orm.relationship('Repository',
            backref=sa.orm.backref('access_grants', cascade="all, delete"))

    def __repr__(self):
        return '<Access {} {}->{}:{}>'.format(
                self.id, self.user_id, self.repo_id, self.mode)

D gitsrht/types/redirect.py => gitsrht/types/redirect.py +0 -19
@@ 1,19 0,0 @@
import sqlalchemy as sa
import sqlalchemy_utils as sau
from srht.database import Base
from enum import Enum

class Redirect(Base):
    __tablename__ = 'redirect'
    id = sa.Column(sa.Integer, primary_key=True)
    created = sa.Column(sa.DateTime, nullable=False)
    name = sa.Column(sa.Unicode(256), nullable=False)
    owner_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False)
    owner = sa.orm.relationship('User')
    path = sa.Column(sa.Unicode(1024))

    new_repo_id = sa.Column(
            sa.Integer,
            sa.ForeignKey('repository.id', ondelete="CASCADE"),
            nullable=False)
    new_repo = sa.orm.relationship('Repository')

D gitsrht/types/repository.py => gitsrht/types/repository.py +0 -26
@@ 1,26 0,0 @@
import sqlalchemy as sa
import sqlalchemy_utils as sau
from srht.database import Base
from enum import Enum

class RepoVisibility(Enum):
    autocreated = 'autocreated'
    """Used for repositories that were created automatically on push"""
    public = 'public'
    private = 'private'
    unlisted = 'unlisted'

class Repository(Base):
    __tablename__ = 'repository'
    id = sa.Column(sa.Integer, primary_key=True)
    created = sa.Column(sa.DateTime, nullable=False)
    updated = sa.Column(sa.DateTime, nullable=False)
    name = sa.Column(sa.Unicode(256), nullable=False)
    description = sa.Column(sa.Unicode(1024))
    owner_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False)
    owner = sa.orm.relationship('User', backref=sa.orm.backref('repos'))
    path = sa.Column(sa.Unicode(1024))
    visibility = sa.Column(
            sau.ChoiceType(RepoVisibility, impl=sa.String()),
            nullable=False,
            default=RepoVisibility.public)

M setup.py => setup.py +1 -1
@@ 49,7 49,7 @@ setup(
  author = 'Drew DeVault',
  author_email = 'sir@cmpwn.com',
  url = 'https://git.sr.ht/~sircmpwn/git.sr.ht',
  install_requires = ['srht', 'flask-login', 'redis<3', 'pygit2', 'pygments'],
  install_requires = ['srht', 'scmsrht', 'pygit2'],
  license = 'AGPL-3.0',
  package_data={
      'gitsrht': [