~edwargix/git.sr.ht

ad42bf4448a9304e5079fe64f3d21d61db1732c5 — Adnan Maolood 2 years ago cd8225a
gitsrht: Store visibility as enum instead of varchar

Add a 'visibility' enum type to the database and use it for the
repository.visibility column.

This required changes to scm.sr.ht code. Instead of updating scm.sr.ht,
most of the scm.sr.ht code that git.sr.ht uses was moved to git.sr.ht.
M api/graph/model/repository.go => api/graph/model/repository.go +4 -18
@@ 3,7 3,6 @@ package model
import (
	"context"
	"database/sql"
	"fmt"
	"strconv"
	"time"



@@ 23,28 22,15 @@ type Repository struct {
	Description *string   `json:"description"`
	Readme      *string   `json:"readme"`

	Path          string
	OwnerID       int
	RawVisibility string
	Path       string
	OwnerID    int
	Visibility Visibility

	alias  string
	repo   *RepoWrapper
	fields *database.ModelFields
}

func (r *Repository) Visibility() Visibility {
	visMap := map[string]Visibility{
		"public":   VisibilityPublic,
		"unlisted": VisibilityUnlisted,
		"private":  VisibilityPrivate,
	}
	vis, ok := visMap[r.RawVisibility]
	if !ok {
		panic(fmt.Errorf("Invalid repo visibility %s", r.RawVisibility)) // Invariant
	}
	return vis
}

func (r *Repository) Repo() *RepoWrapper {
	if r.repo != nil {
		return r.repo


@@ 94,7 80,7 @@ func (r *Repository) Fields() *database.ModelFields {
			{"updated", "updated", &r.Updated},
			{"name", "name", &r.Name},
			{"description", "description", &r.Description},
			{"visibility", "visibility", &r.RawVisibility},
			{"visibility", "visibility", &r.Visibility},
			{"readme", "readme", &r.Readme},

			// Always fetch:

M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +8 -21
@@ 115,19 115,6 @@ func (r *mutationResolver) CreateRepository(ctx context.Context, name string, vi
	}()

	if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
		vismap := map[model.Visibility]string{
			model.VisibilityPublic:   "public",
			model.VisibilityUnlisted: "unlisted",
			model.VisibilityPrivate:  "private",
		}
		var (
			dvis string
			ok   bool
		)
		if dvis, ok = vismap[visibility]; !ok {
			panic(fmt.Errorf("Unknown visibility %s", visibility)) // Invariant
		}

		cloneStatus := CloneNone
		if cloneURL != nil {
			cloneStatus = CloneInProgress


@@ 144,9 131,9 @@ func (r *mutationResolver) CreateRepository(ctx context.Context, name string, vi
			) RETURNING 
				id, created, updated, name, description, visibility,
				path, owner_id;
		`, name, description, repoPath, dvis, user.UserID, cloneStatus)
		`, name, description, repoPath, visibility, user.UserID, cloneStatus)
		if err := row.Scan(&repo.ID, &repo.Created, &repo.Updated, &repo.Name,
			&repo.Description, &repo.RawVisibility,
			&repo.Description, &repo.Visibility,
			&repo.Path, &repo.OwnerID); err != nil {
			if strings.Contains(err.Error(), "duplicate key value violates unique constraint") {
				return valid.Errorf(ctx, "name", "A repository with this name already exists.")


@@ 184,7 171,7 @@ func (r *mutationResolver) CreateRepository(ctx context.Context, name string, vi
		}

		export := path.Join(repoPath, "git-daemon-export-ok")
		if repo.Visibility() != model.VisibilityPrivate {
		if repo.Visibility != model.VisibilityPrivate {
			_, err := os.Create(export)
			if err != nil {
				return err


@@ 361,7 348,7 @@ func (r *mutationResolver) UpdateRepository(ctx context.Context, id int, input m
		valid.OptionalString("visibility", func(vis string) {
			valid.Expect(model.Visibility(vis).IsValid(),
				"Invalid visibility '%s'", vis)
			query = query.Set(`visibility`, strings.ToLower(vis)) // TODO: Can we use uppercase here?
			query = query.Set(`visibility`, vis)
		})

		valid.NullableString("readme", func(readme *string) {


@@ 386,7 373,7 @@ func (r *mutationResolver) UpdateRepository(ctx context.Context, id int, input m

		row := query.RunWith(tx).QueryRowContext(ctx)
		if err := row.Scan(&repo.ID, &repo.Created, &repo.Updated,
			&repo.Name, &repo.Description, &repo.RawVisibility,
			&repo.Name, &repo.Description, &repo.Visibility,
			&repo.Path, &repo.OwnerID); err != nil {
			if err == sql.ErrNoRows {
				return fmt.Errorf("No repository by ID %d found for this user", id)


@@ 418,7 405,7 @@ func (r *mutationResolver) UpdateRepository(ctx context.Context, id int, input m
		}

		export := path.Join(repo.Path, "git-daemon-export-ok")
		if repo.Visibility() == model.VisibilityPrivate {
		if repo.Visibility == model.VisibilityPrivate {
			err := os.Remove(export)
			if err != nil && !errors.Is(err, os.ErrNotExist) {
				return err


@@ 476,7 463,7 @@ func (r *mutationResolver) DeleteRepository(ctx context.Context, id int) (*model
		`, id, auth.ForContext(ctx).UserID)

		if err := row.Scan(&repo.ID, &repo.Created, &repo.Updated,
			&repo.Name, &repo.Description, &repo.RawVisibility,
			&repo.Name, &repo.Description, &repo.Visibility,
			&repo.Path, &repo.OwnerID); err != nil {
			if err == sql.ErrNoRows {
				return fmt.Errorf("No repository by ID %d found for this user", id)


@@ 1302,7 1289,7 @@ func (r *userResolver) Repositories(ctx context.Context, obj *model.User, cursor
				sq.Or{
					sq.Expr(`? IN (access.user_id, repo.owner_id)`,
						auth.ForContext(ctx).UserID),
					sq.Expr(`repo.visibility = 'public'`),
					sq.Expr(`repo.visibility = 'PUBLIC'`),
				},
				sq.Expr(`repo.owner_id = ?`, obj.ID),
			})

M api/loaders/middleware.go => api/loaders/middleware.go +3 -3
@@ 138,7 138,7 @@ func fetchRepositoriesByID(ctx context.Context) func(ids []int) ([]*model.Reposi
					sq.Expr(`repo.id = ANY(?)`, pq.Array(ids)),
					sq.Or{
						sq.Expr(`? IN (access.user_id, repo.owner_id)`, auser.UserID),
						sq.Expr(`repo.visibility != 'private'`),
						sq.Expr(`repo.visibility != 'PRIVATE'`),
					},
				})
			if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {


@@ 205,7 205,7 @@ func fetchRepositoriesByOwnerRepoName(ctx context.Context) func([]OwnerRepoName)
				Where(sq.Or{
					sq.Expr(`? IN (access.user_id, repo.owner_id)`,
						auth.ForContext(ctx).UserID),
					sq.Expr(`repo.visibility != 'private'`),
					sq.Expr(`repo.visibility != 'PRIVATE'`),
				})
			if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {
				panic(err)


@@ 272,7 272,7 @@ func fetchRepositoriesByOwnerIDRepoName(ctx context.Context) func([]OwnerIDRepoN
				Where(sq.Or{
					sq.Expr(`? IN (access.user_id, repo.owner_id)`,
						auth.ForContext(ctx).UserID),
					sq.Expr(`repo.visibility != 'private'`),
					sq.Expr(`repo.visibility != 'PRIVATE'`),
				})
			if rows, err = query.RunWith(tx).QueryContext(ctx); err != nil {
				panic(err)

M api/webhooks/legacy.go => api/webhooks/legacy.go +3 -2
@@ 4,6 4,7 @@ import (
	"context"
	"encoding/json"
	"errors"
	"strings"
	"time"

	"git.sr.ht/~sircmpwn/core-go/auth"


@@ 35,7 36,7 @@ func DeliverLegacyRepoCreate(ctx context.Context, repo *model.Repository) {
		Updated:     repo.Created,
		Name:        repo.Name,
		Description: repo.Description,
		Visibility:  repo.RawVisibility,
		Visibility:  strings.ToLower(repo.Visibility.String()),
	}

	// TODO: User groups


@@ 69,7 70,7 @@ func DeliverLegacyRepoUpdate(ctx context.Context, repo *model.Repository) {
		Updated:     repo.Created,
		Name:        repo.Name,
		Description: repo.Description,
		Visibility:  repo.RawVisibility,
		Visibility:  strings.ToLower(repo.Visibility.String()),
	}

	// TODO: User groups

M gitsrht-periodic => gitsrht-periodic +1 -2
@@ 11,13 11,12 @@ from prometheus_client import CollectorRegistry, Gauge
from prometheus_client.context_managers import Timer
from srht.config import cfg
from srht.database import DbSession
from gitsrht.types import Artifact, User, Repository, RepoVisibility
from gitsrht.types import Artifact, User, Repository
from minio import Minio
from datetime import datetime, timedelta

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

registry = CollectorRegistry()
tg = Gauge("gitsrht_periodic_time",

M gitsrht-shell/main.go => gitsrht-shell/main.go +5 -5
@@ 152,7 152,7 @@ func main() {

	// Fetch the necessary info from SQL. This first query fetches:
	//
	// 1. Repository information, such as visibility (public|unlisted|private)
	// 1. Repository information, such as visibility (PUBLIC|UNLISTED|PRIVATE)
	// 2. Information about the repository owner's account
	// 3. Information about the pusher's account
	// 4. Any access control policies for that repo that apply to the pusher


@@ 266,7 266,7 @@ func main() {

				repoOwnerId = pusherId
				repoOwnerName = pusherName
				repoVisibility = "private"
				repoVisibility = "PRIVATE"

				query := client.GraphQLQuery{
					Query: `


@@ 339,11 339,11 @@ func main() {
	} else {
		if accessGrant == nil {
			switch repoVisibility {
			case "public":
			case "PUBLIC":
				fallthrough
			case "unlisted":
			case "UNLISTED":
				hasAccess = ACCESS_READ
			case "private":
			case "PRIVATE":
				fallthrough
			default:
				hasAccess = ACCESS_NONE

M gitsrht-update-hook/submitter.go => gitsrht-update-hook/submitter.go +1 -1
@@ 232,7 232,7 @@ func (submitter GitBuildSubmitter) GetCommitNote() string {
}

func (submitter GitBuildSubmitter) GetCloneUrl() string {
	if submitter.Visibility == "private" {
	if submitter.Visibility == "PRIVATE" {
		origin := strings.ReplaceAll(submitter.GitOrigin, "http://", "")
		origin = strings.ReplaceAll(origin, "https://", "")
		// Use SSH URL

A gitsrht/access.py => gitsrht/access.py +93 -0
@@ 0,0 1,93 @@
from datetime import datetime
from enum import IntFlag
from flask import abort, current_app, request, redirect, url_for
from gitsrht.types import Access, AccessMode, Repository, Redirect, User, RepoVisibility
from srht.database import db
from srht.oauth import current_user
import sqlalchemy as sa
import sqlalchemy_utils as sau
from sqlalchemy.ext.declarative import declared_attr
from enum import Enum

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

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_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

def get_access(repo, user=None):
	# Note: when updating push access logic, also update git.sr.ht/gitsrht-shell
    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.user_id == user.id,
            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 access in a:
        abort(403)
    return owner, repo

A gitsrht/alembic/versions/64fcd80183c8_add_visibility_enum.py => gitsrht/alembic/versions/64fcd80183c8_add_visibility_enum.py +37 -0
@@ 0,0 1,37 @@
"""Add visibility enum

Revision ID: 64fcd80183c8
Revises: 38952f52f32d
Create Date: 2022-02-24 12:29:23.314019

"""

# revision identifiers, used by Alembic.
revision = '64fcd80183c8'
down_revision = '38952f52f32d'

from alembic import op
import sqlalchemy as sa


def upgrade():
    op.execute("""
    UPDATE repository SET visibility = 'private' WHERE visibility = 'autocreated';

    CREATE TYPE visibility AS ENUM (
        'PUBLIC',
        'PRIVATE',
        'UNLISTED'
    );

    ALTER TABLE repository
    ALTER COLUMN visibility TYPE visibility USING upper(visibility)::visibility;
    """)


def downgrade():
    op.execute("""
    ALTER TABLE repository
    ALTER COLUMN visibility TYPE varchar USING lower(visibility::varchar);
    DROP TYPE visibility;
    """)

M gitsrht/app.py => gitsrht/app.py +21 -9
@@ 4,25 4,24 @@ import stat
from functools import lru_cache
from gitsrht import urls
from gitsrht.git import commit_time, commit_links, trim_commit, signature_time
from gitsrht.repos import GitRepoApi
from gitsrht.service import oauth_service, webhooks_notify
from gitsrht.types import Access, Redirect, Repository, User
from scmsrht.flask import ScmSrhtFlask
from gitsrht.types import User
from srht.config import cfg
from srht.database import DbSession
from srht.flask import session
from srht.database import db, DbSession
from srht.flask import SrhtFlask, session
from jinja2 import FileSystemLoader, ChoiceLoader
from werkzeug.urls import url_quote

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

class GitApp(ScmSrhtFlask):
class GitApp(SrhtFlask):
    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=oauth_service)
                oauth_service=oauth_service)

        from gitsrht.blueprints.auth import auth
        from gitsrht.blueprints.public import public
        from gitsrht.blueprints.api import register_api
        from gitsrht.blueprints.api.plumbing import plumbing
        from gitsrht.blueprints.api.porcelain import porcelain


@@ 32,6 31,9 @@ class GitApp(ScmSrhtFlask):
        from gitsrht.blueprints.repo import repo
        from srht.graphql import gql_blueprint

        self.register_blueprint(auth)
        self.register_blueprint(public)

        register_api(self)
        self.register_blueprint(plumbing)
        self.register_blueprint(porcelain)


@@ 68,6 70,16 @@ class GitApp(ScmSrhtFlask):
                "path_join": os.path.join,
                "stat": stat,
                "trim_commit": trim_commit,
                "lookup_user": self.lookup_user
            }

        choices = [self.jinja_loader, FileSystemLoader(os.path.join(
            os.path.dirname(__file__), "templates"))]
        self.jinja_loader = ChoiceLoader(choices)

        self.url_map.strict_slashes = False

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

app = GitApp()

M gitsrht/blueprints/api/__init__.py => gitsrht/blueprints/api/__init__.py +2 -2
@@ 1,7 1,7 @@
import pkg_resources
from flask import abort
from scmsrht.access import UserAccess, get_access
from scmsrht.types import Repository, User
from gitsrht.access import UserAccess, get_access
from gitsrht.types import Repository, User
from srht.flask import csrf_bypass
from srht.oauth import current_token, oauth


M gitsrht/blueprints/api/info.py => gitsrht/blueprints/api/info.py +7 -7
@@ 1,7 1,7 @@
from flask import Blueprint, Response, current_app, request
from scmsrht.access import UserAccess
from scmsrht.repos import RepoVisibility
from scmsrht.types import Access, Repository, User
import gitsrht.repos as repos
from gitsrht.access import UserAccess
from gitsrht.types import Access, Repository, User, RepoVisibility
from gitsrht.webhooks import UserWebhook
from gitsrht.blueprints.api import get_user, get_repo
from srht.api import paginated_response


@@ 22,12 22,12 @@ def repos_by_user_GET(username):
            .filter(Repository.owner_id == user.id))
    if user.id != current_token.user_id:
        repos = (repos
                .outerjoin(Access._get_current_object(),
                .outerjoin(Access,
                    Access.repo_id == Repository.id)
                .filter(or_(
                    Access.user_id == current_token.user_id,
                    and_(
                        Repository.visibility == RepoVisibility.public,
                        Repository.visibility == RepoVisibility.PUBLIC,
                        Access.id.is_(None))
                )))
    return paginated_response(Repository.id, repos)


@@ 37,7 37,7 @@ def repos_by_user_GET(username):
def repos_POST():
    valid = Validation(request)
    user = current_token.user
    resp = current_app.repo_api.create_repo(valid, user)
    resp = repos.create_repo(valid, user)
    if not valid.ok:
        return valid.response
    # Convert visibility back to lowercase


@@ 103,7 103,7 @@ def repos_by_name_DELETE(reponame):
    user = current_token.user
    repo = get_repo(user, reponame, needs=UserAccess.manage)
    repo_id = repo.id
    current_app.repo_api.delete_repo(repo, user)
    repos.delete_repo(repo, user)
    return {}, 204

@info.route("/api/repos/<reponame>/readme", defaults={"username": None})

M gitsrht/blueprints/api/porcelain.py => gitsrht/blueprints/api/porcelain.py +1 -1
@@ 10,7 10,7 @@ from gitsrht.repos import upload_artifact
from gitsrht.webhooks import RepoWebhook
from io import BytesIO
from itertools import groupby
from scmsrht.access import UserAccess
from gitsrht.access import UserAccess
from gitsrht.blueprints.api import get_user, get_repo
from srht.api import paginated_response
from srht.database import db

M gitsrht/blueprints/artifacts.py => gitsrht/blueprints/artifacts.py +1 -1
@@ 7,7 7,7 @@ from gitsrht.git import Repository as GitRepository, strip_pgp_signature
from gitsrht.repos import delete_artifact, upload_artifact
from gitsrht.types import Artifact
from minio import Minio
from scmsrht.access import check_access, get_repo_or_redir, UserAccess
from gitsrht.access import check_access, UserAccess
from srht.config import cfg
from srht.database import db
from srht.oauth import loginrequired

A gitsrht/blueprints/auth.py => gitsrht/blueprints/auth.py +22 -0
@@ 0,0 1,22 @@
import os
from flask import Blueprint, request
from gitsrht.access import get_repo, has_access, UserAccess
from urllib.parse import urlparse, unquote

auth = Blueprint("auth", __name__)

@auth.route("/authorize")
def authorize_http_access():
    original_uri = request.headers.get("X-Original-URI")
    original_uri = urlparse(original_uri)
    path = unquote(original_uri.path)
    original_path = os.path.normpath(path).split('/')
    if len(original_path) < 3:
        return "authorized", 200
    owner, repo = original_path[1], original_path[2]
    owner, repo = get_repo(owner, repo)
    if not repo:
        return "unauthorized", 403
    if not has_access(repo, UserAccess.read):
        return "unauthorized", 403
    return "authorized", 200

M gitsrht/blueprints/email.py => gitsrht/blueprints/email.py +1 -1
@@ 13,7 13,7 @@ from flask import Blueprint, render_template, abort, request, url_for, session
from flask import redirect
from gitsrht.git import Repository as GitRepository, commit_time, diffstat
from gitsrht.git import get_log
from scmsrht.access import get_repo_or_redir
from gitsrht.access import get_repo_or_redir
from srht.config import cfg, cfgi, cfgb
from srht.oauth import loginrequired, current_user
from srht.validation import Validation

M gitsrht/blueprints/manage.py => gitsrht/blueprints/manage.py +15 -23
@@ 1,6 1,7 @@
import pygit2
from flask import Blueprint, current_app, request, render_template, abort
from flask import redirect, url_for
import gitsrht.repos as repos
from gitsrht.git import Repository as GitRepository
from srht.config import cfg
from srht.database import db


@@ 8,11 9,8 @@ from srht.flask import session
from srht.graphql import exec_gql, GraphQLError
from srht.oauth import current_user, loginrequired, UserType
from srht.validation import Validation
from scmsrht.access import check_access, UserAccess
from scmsrht.repos.access import AccessMode
from scmsrht.repos.redirect import BaseRedirectMixin
from scmsrht.repos.repository import RepoVisibility
from scmsrht.types import Access, User
from gitsrht.access import check_access, UserAccess, AccessMode
from gitsrht.types import Access, User, Redirect
import shutil
import os



@@ 28,10 26,8 @@ def create_GET():
@manage.route("/create", methods=["POST"])
@loginrequired
def create_POST():
    if not current_app.repo_api:
        abort(501)
    valid = Validation(request)
    resp = current_app.repo_api.create_repo(valid)
    resp = repos.create_repo(valid)
    if not valid.ok:
        return render_template("create.html", **valid.kwargs)



@@ 51,10 47,8 @@ def clone():
@manage.route("/clone", methods=["POST"])
@loginrequired
def clone_POST():
    if not current_app.repo_api:
        abort(501)
    valid = Validation(request)
    resp = current_app.repo_api.clone_repo(valid)
    resp = repos.clone_repo(valid)
    if not valid.ok:
        return render_template("clone.html", **valid.kwargs)
    return redirect(url_for("repo.summary",


@@ 64,7 58,7 @@ def clone_POST():
@loginrequired
def settings_info(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
    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)


@@ 73,7 67,7 @@ def settings_info(owner_name, repo_name):
@loginrequired
def settings_info_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
    if isinstance(repo, Redirect):
        repo = repo.new_repo

    valid = Validation(request)


@@ 101,7 95,7 @@ def settings_info_POST(owner_name, repo_name):
@loginrequired
def settings_rename(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
    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)


@@ 110,7 104,7 @@ def settings_rename(owner_name, repo_name):
@loginrequired
def settings_rename_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
    if isinstance(repo, Redirect):
        repo = repo.new_repo

    valid = Validation(request)


@@ 138,7 132,7 @@ def settings_rename_POST(owner_name, repo_name):
@loginrequired
def settings_access(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
    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)


@@ 147,7 141,7 @@ def settings_access(owner_name, repo_name):
@loginrequired
def settings_access_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
    if isinstance(repo, Redirect):
        repo = repo.new_repo
    valid = Validation(request)
    username = valid.require("user", friendly_name="User")


@@ 190,7 184,7 @@ def settings_access_POST(owner_name, repo_name):
@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, BaseRedirectMixin):
    if isinstance(repo, Redirect):
        repo = repo.new_repo
    grant = (Access.query
        .filter(Access.repo_id == repo.id, Access.id == grant_id)


@@ 206,7 200,7 @@ def settings_access_revoke_POST(owner_name, repo_name, grant_id):
@loginrequired
def settings_delete(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
    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)


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

A gitsrht/blueprints/public.py => gitsrht/blueprints/public.py +54 -0
@@ 0,0 1,54 @@
from flask import Blueprint, current_app, request
from flask import render_template, abort
from srht.config import cfg
from srht.flask import paginate_query
from srht.oauth import current_user
from srht.search import search_by
from gitsrht.types import Access, Repository, User, RepoVisibility
from sqlalchemy import and_, or_

public = Blueprint('public', __name__)

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

@public.route("/~<username>")
@public.route("/~<username>/")
def user_index(username):
    user = User.query.filter(User.username == username).first()
    if not user:
        abort(404)
    terms = request.args.get("search")
    repos = (Repository.query
            .filter(Repository.owner_id == user.id))
    if current_user and current_user.id != user.id:
        repos = (repos
                .outerjoin(Access,
                    Access.repo_id == Repository.id)
                .filter(or_(
                    Access.user_id == current_user.id,
                    Repository.visibility == RepoVisibility.PUBLIC,
                )))
    elif not current_user:
        repos = repos.filter(Repository.visibility == RepoVisibility.PUBLIC)

    search_error = None
    try:
        repos = search_by(repos, terms,
                [Repository.name, Repository.description])
    except ValueError as ex:
        search_error = str(ex)

    repos = repos.order_by(Repository.updated.desc())
    repos, pagination = paginate_query(repos)

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

M gitsrht/blueprints/repo.py => gitsrht/blueprints/repo.py +1 -1
@@ 19,7 19,7 @@ from jinja2.utils import url_quote, escape
from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import guess_lexer, guess_lexer_for_filename, TextLexer
from scmsrht.access import get_repo, get_repo_or_redir
from gitsrht.access import get_repo, get_repo_or_redir
from scmsrht.formatting import get_formatted_readme, get_highlighted_file
from scmsrht.urls import get_clone_urls
from srht.config import cfg, get_origin

M gitsrht/repos.py => gitsrht/repos.py +67 -71
@@ 6,7 6,6 @@ import shutil
import subprocess
from gitsrht.types import Artifact, Repository, Redirect
from minio import Minio
from scmsrht.repos.repository import RepoVisibility
from srht.config import cfg
from srht.database import db
from srht.graphql import exec_gql, GraphQLError


@@ 78,85 77,82 @@ def upload_artifact(valid, repo, commit, f, filename):
    db.session.add(artifact)
    return artifact

# TODO: Remove repo API wrapper class
def get_repo_path(owner, repo_name):
    return os.path.join(repos_path, "~" + owner.username, repo_name)

class GitRepoApi():
    def get_repo_path(self, owner, repo_name):
        return os.path.join(repos_path, "~" + owner.username, repo_name)

    def create_repo(self, valid, user=None):
        repo_name = valid.require("name", friendly_name="Name")
        description = valid.optional("description")
        visibility = valid.optional("visibility")
        if not valid.ok:
            return None
def create_repo(valid, user=None):
    repo_name = valid.require("name", friendly_name="Name")
    description = valid.optional("description")
    visibility = valid.optional("visibility")
    if not valid.ok:
        return None

        # Convert the visibility to uppercase. This is needed for the REST API
        # TODO: Remove this when the REST API is phased out
        if visibility is not None:
            visibility = visibility.upper()
    # Convert the visibility to uppercase. This is needed for the REST API
    # TODO: Remove this when the REST API is phased out
    if visibility is not None:
        visibility = visibility.upper()

        resp = exec_gql("git.sr.ht", """
            mutation CreateRepository(
                    $name: String!,
                    $visibility: Visibility = PUBLIC,
                    $description: String) {
                createRepository(
                        name: $name,
                        visibility: $visibility,
                        description: $description) {
                    id
                    created
                    updated
                    name
                    owner {
                        canonicalName
                        ... on User {
                            name: username
                        }
    resp = exec_gql("git.sr.ht", """
        mutation CreateRepository(
                $name: String!,
                $visibility: Visibility = PUBLIC,
                $description: String) {
            createRepository(
                    name: $name,
                    visibility: $visibility,
                    description: $description) {
                id
                created
                updated
                name
                owner {
                    canonicalName
                    ... on User {
                        name: username
                    }
                    description
                    visibility
                }
                description
                visibility
            }
        """, valid=valid, user=user, name=repo_name,
            description=description, visibility=visibility)
        }
    """, valid=valid, user=user, name=repo_name,
        description=description, visibility=visibility)

        if not valid.ok:
            return None
        return resp["createRepository"]
    if not valid.ok:
        return None
    return resp["createRepository"]

    def clone_repo(self, valid):
        cloneUrl = valid.require("cloneUrl", friendly_name="Clone URL")
        name = valid.require("name", friendly_name="Name")
        description = valid.optional("description")
        visibility = valid.optional("visibility")
        if not valid.ok:
            return None
def clone_repo(valid):
    cloneUrl = valid.require("cloneUrl", friendly_name="Clone URL")
    name = valid.require("name", friendly_name="Name")
    description = valid.optional("description")
    visibility = valid.optional("visibility")
    if not valid.ok:
        return None

        resp = exec_gql("git.sr.ht", """
            mutation CreateRepository(
                    $name: String!,
                    $visibility: Visibility = UNLISTED,
                    $description: String,
                    $cloneUrl: String) {
                createRepository(name: $name,
                        visibility: $visibility,
                        description: $description,
                        cloneUrl: $cloneUrl) {
                    name
                }
    resp = exec_gql("git.sr.ht", """
        mutation CreateRepository(
                $name: String!,
                $visibility: Visibility = UNLISTED,
                $description: String,
                $cloneUrl: String) {
            createRepository(name: $name,
                    visibility: $visibility,
                    description: $description,
                    cloneUrl: $cloneUrl) {
                name
            }
        """, valid=valid, name=name, visibility=visibility,
            description=description, cloneUrl=cloneUrl)
        }
    """, valid=valid, name=name, visibility=visibility,
        description=description, cloneUrl=cloneUrl)

        if not valid.ok:
            return None
        return resp["createRepository"]
    if not valid.ok:
        return None
    return resp["createRepository"]

    def delete_repo(self, repo, user=None):
        exec_gql("git.sr.ht", """
            mutation DeleteRepository($id: Int!) {
                deleteRepository(id: $id) { id }
            }
        """, user=user, id=repo.id)
def delete_repo(repo, user=None):
    exec_gql("git.sr.ht", """
        mutation DeleteRepository($id: Int!) {
            deleteRepository(id: $id) { id }
        }
    """, user=user, id=repo.id)

A gitsrht/templates/base.html => gitsrht/templates/base.html +5 -0
@@ 0,0 1,5 @@
{% extends "layout.html" %}

{% block body %}
{% block content %}{% endblock %}
{% endblock %}

M gitsrht/templates/dashboard.html => gitsrht/templates/dashboard.html +2 -2
@@ 30,9 30,9 @@
                     owner=current_user.canonical_name,
                     repo=repo.name)}}"
            >~{{current_user.username}}/{{repo.name}}</a>
            {% if repo.visibility.value != 'public' %}
            {% if repo.visibility.value != 'PUBLIC' %}
            <small class="pull-right">
              {{ repo.visibility.value }}
              {{ repo.visibility.value.lower() }}
            </small>
            {% endif %}
          </h4>

M gitsrht/templates/repo.html => gitsrht/templates/repo.html +6 -6
@@ 2,7 2,7 @@
{% block head %}
{% block repohead %}
{% endblock %}
{% if repo.visibility.value =='unlisted' %}
{% if repo.visibility.value == 'UNLISTED' %}
<meta name="robots" content="noindex">
{% endif %}
{# VCS meta tags #}


@@ 44,18 44,18 @@
      >{{owner.canonical_name}}</a>/<wbr>{{repo.name}}
    </h2>
    <ul class="nav nav-tabs">
      {% if repo.visibility.value != "public" %}
      {% if repo.visibility.value != "PUBLIC" %}
      <li
        class="nav-item nav-text vis-{{repo.visibility.value.lower()}}"
        {% if repo.visibility.value == "unlisted" %}
        {% if repo.visibility.value == "UNLISTED" %}
        title="This repository is only visible to those who know the URL."
        {% elif repo.visibility.value == "private" %}
        {% elif repo.visibility.value == "PRIVATE" %}
        title="This repository is only visible to those who were invited to view it."
        {% endif %}
      >
        {% if repo.visibility.value == "unlisted" %}
        {% if repo.visibility.value == "UNLISTED" %}
        Unlisted
        {% elif repo.visibility.value == "private" %}
        {% elif repo.visibility.value == "PRIVATE" %}
        Private
        {% endif %}
      </li>

M gitsrht/templates/settings_info.html => gitsrht/templates/settings_info.html +3 -3
@@ 43,7 43,7 @@
              type="radio"
              name="visibility"
              value="PUBLIC"
              {{ "checked" if repo.visibility.value == "public" else "" }}
              {{ "checked" if repo.visibility.value == "PUBLIC" else "" }}
            > Public
          </label>
        </div>


@@ 57,7 57,7 @@
              type="radio"
              name="visibility"
              value="UNLISTED"
              {{ "checked" if repo.visibility.value == "unlisted" else "" }}
              {{ "checked" if repo.visibility.value == "UNLISTED" else "" }}
            > Unlisted
          </label>
        </div>


@@ 71,7 71,7 @@
              type="radio"
              name="visibility"
              value="PRIVATE"
              {{ "checked" if repo.visibility.value == "private" else "" }}
              {{ "checked" if repo.visibility.value == "PRIVATE" else "" }}
            > Private
          </label>
        </div>

M gitsrht/templates/summary.html => gitsrht/templates/summary.html +2 -2
@@ 92,7 92,7 @@
            <input type="hidden" name="cloneUrl" value="{{(repo|clone_urls)[0]}}" />
            <input type="hidden" name="name" value="{{repo.name}}" />
            <input type="hidden" name="description" value="Clone of {{(repo|clone_urls)[0]}}" />
            <input type="hidden" name="visibility" value="{% if repo.visibility.value == 'private' %}PRIVATE{% else %}UNLISTED{% endif %}" />
            <input type="hidden" name="visibility" value="{% if repo.visibility.value == 'PRIVATE' %}PRIVATE{% else %}UNLISTED{% endif %}" />
            <button type="submit" class="btn btn-primary btn-block">
              Clone repo to your account {{icon('caret-right')}}
            </button>


@@ 121,7 121,7 @@
    </div>
  </div>
  {% if current_user == repo.owner and not license
    and repo.visibility.value == 'public' %}
    and repo.visibility.value == 'PUBLIC' %}
  <div class="alert alert-danger">
    <strong>Heads up!</strong> We couldn't find a license file for your
    repository, which means that it may not be possible for others to use this

M gitsrht/templates/user.html => gitsrht/templates/user.html +2 -2
@@ 62,9 62,9 @@
            <a href="/~{{user.username}}/{{repo.name}}">
              ~{{user.username}}/{{repo.name}}
            </a>
            {% if repo.visibility.value != 'public' %}
            {% if repo.visibility.value != 'PUBLIC' %}
            <small class="pull-right">
              {{ repo.visibility.value }}
              {{ repo.visibility.value.lower() }}
            </small>
            {% endif %}
          </h4>

M gitsrht/types/__init__.py => gitsrht/types/__init__.py +84 -11
@@ 7,8 7,7 @@ from sqlalchemy.dialects import postgresql
from srht.database import Base
from srht.oauth import ExternalUserMixin, ExternalOAuthTokenMixin
from gitsrht.git import Repository as GitRepository
from scmsrht.repos import BaseAccessMixin, BaseRedirectMixin
from scmsrht.repos import RepoVisibility
from enum import Enum

class User(Base, ExternalUserMixin):
    pass


@@ 16,11 15,86 @@ class User(Base, ExternalUserMixin):
class OAuthToken(Base, ExternalOAuthTokenMixin):
    pass

class Access(Base, BaseAccessMixin):
    pass
class AccessMode(Enum):
    ro = 'ro'
    rw = 'rw'

class Redirect(Base, BaseRedirectMixin):
    pass
class Access(Base):
    @declared_attr
    def __tablename__(cls):
        return "access"

    @declared_attr
    def __table_args__(cls):
        return (
            sa.UniqueConstraint('user_id', 'repo_id',
                name="uq_access_user_id_repo_id"),
        )

    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)

    @declared_attr
    def user_id(cls):
        return sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False)

    @declared_attr
    def user(cls):
        return sa.orm.relationship('User', backref='access_grants')

    @declared_attr
    def repo_id(cls):
        return sa.Column(sa.Integer,
            sa.ForeignKey('repository.id', ondelete="CASCADE"),
            nullable=False)

    @declared_attr
    def repo(cls):
        return  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)

class Redirect(Base):
    @declared_attr
    def __tablename__(cls):
        return "redirect"

    id = sa.Column(sa.Integer, primary_key=True)
    created = sa.Column(sa.DateTime, nullable=False)
    name = sa.Column(sa.Unicode(256), nullable=False)
    path = sa.Column(sa.Unicode(1024))

    @declared_attr
    def owner_id(cls):
        return sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False)

    @declared_attr
    def owner(cls):
        return sa.orm.relationship('User')

    @declared_attr
    def new_repo_id(cls):
        return sa.Column(
            sa.Integer,
            sa.ForeignKey('repository.id', ondelete="CASCADE"),
            nullable=False)

    @declared_attr
    def new_repo(cls):
        return sa.orm.relationship('Repository')

class RepoVisibility(Enum):
    # NOTE: SQLAlchemy uses the enum member names, not the values.
    # The values are used by templates. Therfore, we capitalize both.
    PUBLIC = 'PUBLIC'
    PRIVATE = 'PRIVATE'
    UNLISTED = 'UNLISTED'

class Repository(Base):
    @declared_attr


@@ 41,10 115,7 @@ class Repository(Base):
    name = sa.Column(sa.Unicode(256), nullable=False)
    description = sa.Column(sa.Unicode(1024))
    path = sa.Column(sa.Unicode(1024))
    visibility = sa.Column(
            sau.ChoiceType(RepoVisibility, impl=sa.String()),
            nullable=False,
            default=RepoVisibility.public)
    visibility = sa.Column(postgresql.ENUM(RepoVisibility), nullable=False)
    readme = sa.Column(sa.Unicode)
    clone_status = sa.Column(postgresql.ENUM(
        'NONE', 'IN_PROGRESS', 'COMPLETE', 'ERROR'), nullable=False)


@@ 58,6 129,8 @@ class Repository(Base):
    def owner(cls):
        return sa.orm.relationship('User', backref=sa.orm.backref('repos'))

    # This is only used by the REST API
    # TODO: Remove this when the REST API is phased out
    def to_dict(self):
        return {
            "id": self.id,


@@ 66,7 139,7 @@ class Repository(Base):
            "name": self.name,
            "owner": self.owner.to_dict(short=True),
            "description": self.description,
            "visibility": self.visibility,
            "visibility": self.visibility.value.lower(),
        }

    @property