~edwargix/git.sr.ht

f1a18c569fd13476eff0102d7410401a6c30ff69 — Drew DeVault 5 years ago f9d5e52
Add support for attaching binaries to git tags

This is facilitated via an s3-compatible object storage service. We
recommend the use of Minio, a free software project which provides an
s3-compatible API.
M config.example.ini => config.example.ini +15 -0
@@ 36,6 36,15 @@ network-key=
# ill effect, if this better suits your infrastructure.
redis-host=

[objects]
# Configure the S3-compatible object storage service. Leave empty to disable
# object storage.
#
# Minio is recommended as a FOSS solution over AWS: https://min.io
s3-upstream=
s3-access-key=
s3-secret-key=

[mail]
#
# Outgoing SMTP settings


@@ 97,6 106,12 @@ oauth-client-secret=CHANGEME
#
# Path to git repositories on disk
repos=/var/lib/git/
#
# Configure the S3 bucket and prefix for object storage. Leave empty to disable
# object storage. Bucket is required to enable object storage; prefix is
# optional.
s3-bucket=
s3-prefix=

[git.sr.ht::dispatch]
#

A gitsrht/alembic/versions/b4a2f2ed61b2_add_artifact_table.py => gitsrht/alembic/versions/b4a2f2ed61b2_add_artifact_table.py +29 -0
@@ 0,0 1,29 @@
"""Add artifact table

Revision ID: b4a2f2ed61b2
Revises: 1152333caa0b
Create Date: 2020-02-14 12:00:52.658629

"""

# revision identifiers, used by Alembic.
revision = 'b4a2f2ed61b2'
down_revision = '1152333caa0b'

from alembic import op
import sqlalchemy as sa


def upgrade():
    op.create_table("artifacts",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("created", sa.DateTime, nullable=False),
        sa.Column("user_id", sa.Integer, sa.ForeignKey('user.id'), nullable=False),
        sa.Column("repo_id", sa.Integer, sa.ForeignKey('repository.id'), nullable=False),
        sa.Column("commit", sa.Unicode, nullable=False),
        sa.Column("filename", sa.Unicode, nullable=False),
        sa.Column("checksum", sa.Unicode, nullable=False),
        sa.Column("size", sa.Integer, nullable=False))

def downgrade():
    op.drop_table("artifacts")

M gitsrht/app.py => gitsrht/app.py +9 -3
@@ 23,6 23,7 @@ class GitApp(ScmSrhtFlask):
                repo_api=GitRepoApi(), oauth_service=oauth_service)

        from gitsrht.blueprints.api import data
        from gitsrht.blueprints.artifacts import artifacts
        from gitsrht.blueprints.email import mail
        from gitsrht.blueprints.repo import repo
        from gitsrht.blueprints.stats import stats


@@ 33,6 34,10 @@ class GitApp(ScmSrhtFlask):
        self.register_blueprint(stats)
        self.register_blueprint(webhooks_notify)

        from gitsrht.repos import object_storage_enabled
        if object_storage_enabled:
            self.register_blueprint(artifacts)

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


@@ 44,11 49,12 @@ class GitApp(ScmSrhtFlask):
                del session["notice"]
            return {
                "commit_time": commit_time,
                "trim_commit": trim_commit,
                "humanize": humanize,
                "stat": stat,
                "notice": notice,
                "path_join": os.path.join
                "object_storage_enabled": object_storage_enabled,
                "path_join": os.path.join,
                "stat": stat,
                "trim_commit": trim_commit,
            }

app = GitApp()

A gitsrht/blueprints/artifacts.py => gitsrht/blueprints/artifacts.py +110 -0
@@ 0,0 1,110 @@
import hashlib
import os
import pygit2
from flask import Blueprint, redirect, render_template, request, redirect
from flask import abort, url_for
from gitsrht.git import Repository as GitRepository
from gitsrht.repos import delete_artifact, upload_artifact
from gitsrht.types import Artifact
from minio import Minio
from minio.error import BucketAlreadyOwnedByYou, BucketAlreadyExists
from scmsrht.access import check_access, UserAccess
from srht.config import cfg
from srht.database import db
from srht.oauth import loginrequired
from srht.validation import Validation
from werkzeug.utils import secure_filename

artifacts = Blueprint('artifacts', __name__)

# TODO: Make S3 support optional
s3_upstream = cfg("objects", "s3-upstream", default=None)
s3_access_key = cfg("objects", "s3-access-key", default=None)
s3_secret_key = cfg("objects", "s3-secret-key", default=None)
s3_bucket = cfg("git.sr.ht", "s3-bucket", default=None)
s3_prefix = cfg("git.sr.ht", "s3-prefix", default=None)

@artifacts.route("/<owner>/<repo>/refs/<ref>/upload", methods=["POST"])
@loginrequired
def ref_upload(owner, repo, ref):
    owner, repo = check_access(owner, repo, UserAccess.manage)
    with GitRepository(repo.path) as git_repo:
        try:
            tag = git_repo.revparse_single(ref)
        except KeyError:
            abort(404)
        except ValueError:
            abort(404)
        if isinstance(tag, pygit2.Commit):
            target = tag.oid.hex
        else:
            target = tag.target.hex
        valid = Validation(request)
        f = request.files.get("file")
        valid.expect(f, "File is required", field="file")
        if not valid.ok:
            return render_template("ref.html", view="refs",
                    owner=owner, repo=repo, git_repo=git_repo, tag=tag,
                    **valid.kwargs)
        artifact = upload_artifact(valid, repo, target, f, f.filename)
        if not valid.ok:
            return render_template("ref.html", view="refs",
                    owner=owner, repo=repo, git_repo=git_repo, tag=tag,
                    **valid.kwargs)
        db.session.commit()
        return redirect(url_for("repo.ref",
            owner=owner.canonical_name,
            repo=repo.name,
            ref=ref))

@artifacts.route("/<owner>/<repo>/refs/<ref>/<filename>")
def ref_download(owner, repo, ref, filename):
    owner, repo = check_access(owner, repo, UserAccess.read)
    with GitRepository(repo.path) as git_repo:
        try:
            tag = git_repo.revparse_single(ref)
        except KeyError:
            abort(404)
        except ValueError:
            abort(404)
        if isinstance(tag, pygit2.Commit):
            target = tag.oid.hex
        else:
            target = tag.target.hex
    artifact = (Artifact.query
            .filter(Artifact.user_id == owner.id)
            .filter(Artifact.repo_id == repo.id)
            .filter(Artifact.commit == target)
            .filter(Artifact.filename == filename)).one_or_none()
    if not artifact:
        abort(404)
    prefix = os.path.join(s3_prefix, "artifacts",
            repo.owner.canonical_name, repo.name)
    url = f"https://{s3_upstream}/{s3_bucket}/{prefix}/{filename}"
    return redirect(url)

@artifacts.route("/<owner>/<repo>/refs/<ref>/<filename>", methods=["POST"])
def ref_delete(owner, repo, ref, filename):
    owner, repo = check_access(owner, repo, UserAccess.manage)
    with GitRepository(repo.path) as git_repo:
        try:
            tag = git_repo.revparse_single(ref)
        except KeyError:
            abort(404)
        except ValueError:
            abort(404)
        if isinstance(tag, pygit2.Commit):
            target = tag.oid.hex
        else:
            target = tag.target.hex
    artifact = (Artifact.query
            .filter(Artifact.user_id == owner.id)
            .filter(Artifact.repo_id == repo.id)
            .filter(Artifact.commit == target)
            .filter(Artifact.filename == filename)).one_or_none()
    if not artifact:
        abort(404)
    delete_artifact(artifact)
    db.session.commit()
    return redirect(url_for("repo.ref",
        owner=owner.canonical_name, repo=repo.name, ref=ref))

M gitsrht/blueprints/repo.py => gitsrht/blueprints/repo.py +6 -2
@@ 13,6 13,7 @@ from gitsrht.editorconfig import EditorConfig
from gitsrht.git import Repository as GitRepository, commit_time, annotate_tree
from gitsrht.git import diffstat, get_log
from gitsrht.rss import generate_feed
from gitsrht.types import Artifact
from io import BytesIO
from jinja2 import Markup
from pygments import highlight


@@ 486,7 487,6 @@ def refs_rss(owner, repo):

    return generate_feed(repo, references, title, link, description)


@repo.route("/<owner>/<repo>/refs/<ref>")
def ref(owner, repo, ref):
    owner, repo = get_repo_or_redir(owner, repo)


@@ 500,5 500,9 @@ def ref(owner, repo, ref):
        if isinstance(tag, pygit2.Commit):
            return redirect(url_for(".commit",
                owner=owner, repo=repo.name, ref=tag.id.hex))
        artifacts = (Artifact.query
                .filter(Artifact.user_id == repo.owner_id)
                .filter(Artifact.repo_id == repo.id)).all()
        return render_template("ref.html", view="refs",
                owner=owner, repo=repo, git_repo=git_repo, tag=tag)
                owner=owner, repo=repo, git_repo=git_repo, tag=tag,
                artifacts=artifacts)

M gitsrht/repos.py => gitsrht/repos.py +77 -2
@@ 1,12 1,82 @@
import hashlib
import os.path
import subprocess
from gitsrht.types import Repository, Redirect
from gitsrht.types import Artifact, Repository, Redirect
from minio import Minio
from minio.error import BucketAlreadyOwnedByYou, BucketAlreadyExists, ResponseError
from scmsrht.repos import SimpleRepoApi
from srht.config import cfg
import os.path
from srht.database import db
from werkzeug.utils import secure_filename

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

s3_upstream = cfg("objects", "s3-upstream", default=None)
s3_access_key = cfg("objects", "s3-access-key", default=None)
s3_secret_key = cfg("objects", "s3-secret-key", default=None)
s3_bucket = cfg("git.sr.ht", "s3-bucket", default=None)
s3_prefix = cfg("git.sr.ht", "s3-prefix", default=None)

object_storage_enabled = all([
    s3_upstream,
    s3_access_key,
    s3_secret_key,
    s3_bucket,
])

def delete_artifact(artifact):
    minio = Minio(s3_upstream, access_key=s3_access_key,
            secret_key=s3_secret_key, secure=True)
    repo = artifact.repo
    prefix = os.path.join(s3_prefix, "artifacts",
            repo.owner.canonical_name, repo.name)
    try:
        minio.remove_object(s3_bucket, f"{prefix}/{artifact.filename}")
    except ResponseError as err:
        print(err)
    db.session.delete(artifact)

def upload_artifact(valid, repo, commit, f, filename):
    fn = secure_filename(filename)
    artifact = (Artifact.query
            .filter(Artifact.user_id == repo.owner_id)
            .filter(Artifact.repo_id == repo.id)
            .filter(Artifact.commit == commit)
            .filter(Artifact.filename == fn)).one_or_none()
    valid.expect(not artifact, "A file by this name was already uploaded.",
            field="file")
    if not valid.ok:
        return None
    minio = Minio(s3_upstream, access_key=s3_access_key,
            secret_key=s3_secret_key, secure=True)
    prefix = os.path.join(s3_prefix, "artifacts",
            repo.owner.canonical_name, repo.name)
    try:
        minio.make_bucket(s3_bucket)
    except BucketAlreadyOwnedByYou:
        pass
    except BucketAlreadyExists:
        pass
    sha = hashlib.sha256()
    buf = f.read(1024)
    while len(buf) > 0:
        sha.update(buf)
        buf = f.read(1024)
    size = f.tell()
    f.seek(0)
    minio.put_object(s3_bucket, f"{prefix}/{fn}", f, size,
            content_type="application/octet-stream")
    artifact = Artifact()
    artifact.user_id = repo.owner_id
    artifact.repo_id = repo.id
    artifact.commit = commit
    artifact.filename = fn
    artifact.checksum = f"sha256:{sha.hexdigest()}"
    artifact.size = size
    db.session.add(artifact)
    return artifact

class GitRepoApi(SimpleRepoApi):
    def __init__(self):
        super().__init__(repos_path,


@@ 34,6 104,11 @@ class GitRepoApi(SimpleRepoApi):
        from gitsrht.webhooks import RepoWebhook
        RepoWebhook.Subscription.query.filter(
                RepoWebhook.Subscription.repo_id == repo.id).delete()
        # TODO: Should we delete these asyncronously?
        for artifact in (Artifact.query
                .filter(Artifact.user_id == repo.owner_id)
                .filter(Artifact.repo_id == repo.id)):
            delete_artifact(artifact)
        super().do_delete_repo(repo)

    def do_clone_repo(self, source, repo):

M gitsrht/templates/ref.html => gitsrht/templates/ref.html +76 -20
@@ 5,36 5,92 @@
{% endblock %}
{% block content %}
<div class="container">
  <h3>
    {{tag.name}}
    <small class="pull-right text-muted">
      {{commit_time(tag) | date}}
    </small>
  </h3>
  <div class="row">
    <div class="col-md-4">
      <div class="form-group">
        <a
          class="btn btn-primary btn-block"
          href="{{url_for("repo.archive",
            owner=repo.owner.canonical_name,
            repo=repo.name, ref=tag.name)}}"
        >.tar.gz {{icon("caret-right")}}</a>
        <a
          class="btn btn-default btn-block"
          href="{{url_for("repo.tree",
            owner=repo.owner.canonical_name,
            repo=repo.name, ref=tag.name)}}"
        >browse {{icon("caret-right")}}</a>
      </div>
      {% if object_storage_enabled %}
      {% if any(artifacts) %}
      <div class="event-list">
        {% for artifact in artifacts %}
        <form
          class="event"
          method="POST"
          action="{{url_for("artifacts.ref_delete",
            owner=repo.owner.canonical_name,
            repo=repo.name, ref=tag.name,
            filename=artifact.filename)}}"
        >
          <a
            href="{{url_for("artifacts.ref_download",
              owner=repo.owner.canonical_name,
              repo=repo.name, ref=tag.name,
              filename=artifact.filename)}}"
          >{{ artifact.filename }} {{icon('caret-right')}}</a><br />
          <span
            class="text-muted"
            title="{{artifact.checksum}}"
          >{{artifact.checksum}}</span>
          {% if repo.owner == current_user %}
          {{csrf_token()}}
          <button
            type="submit"
            class="btn btn-danger btn-sm pull-right"
          >Delete this file {{icon('caret-right')}}</button>
          {% endif %}
        </form>
        {% endfor %}
      </div>
      {% endif %}
      <form
        method="POST"
        action="{{url_for('artifacts.ref_upload',
          owner=repo.owner.canonical_name, repo=repo.name, ref=tag.name)}}"
        enctype="multipart/form-data"
      >
        {{csrf_token()}}
        <div class="form-group">
          <label for="file">Attach file to this ref</label>
          <input
            type="file"
            name="file"
            id="file"
            class="form-control {{valid.cls("file")}}" />
          {{valid.summary("file")}}
        </div>
        <button type="submit" class="btn btn-default pull-right">
          Upload file {{icon('caret-right')}}
        </button>
      </form>
      {% endif %}
    </div>
    <div class="col-md-8">
      <div class="event-list">
        <div class="event">
          <h3 style="margin-bottom: 0.5rem">
            {{tag.name}}
            <small class="pull-right text-muted">
              {{commit_time(tag) | date}}
            </small>
          </h3>
          {% if tag.message %}
          <pre style="padding-bottom: 0;">{{tag.message}}</pre>
          {% endif %}
        </div>
      </div>
    </div>
    <div class="col-md-4">
      <a
        class="btn btn-primary btn-block"
        href="{{url_for("repo.archive",
          owner=repo.owner.canonical_name,
          repo=repo.name, ref=tag.name)}}"
      >.tar.gz {{icon("caret-right")}}</a>
      <a
        class="btn btn-default btn-block"
        href="{{url_for("repo.tree",
          owner=repo.owner.canonical_name,
          repo=repo.name, ref=tag.name)}}"
      >browse {{icon("caret-right")}}</a>
    </div>
  </div>
</div>
{% endblock %}

M gitsrht/types/__init__.py => gitsrht/types/__init__.py +1 -0
@@ 19,4 19,5 @@ class Redirect(Base, BaseRedirectMixin):
class Repository(Base, BaseRepositoryMixin):
    pass

from gitsrht.types.artifact import Artifact
from gitsrht.types.sshkey import SSHKey

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

class Artifact(Base):
    __tablename__ = 'artifacts'
    id = sa.Column(sa.Integer, primary_key=True)
    created = sa.Column(sa.DateTime, nullable=False)
    user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False)
    user = sa.orm.relationship('User')
    repo_id = sa.Column(sa.Integer, sa.ForeignKey('repository.id'), nullable=False)
    repo = sa.orm.relationship('Repository')
    commit = sa.Column(sa.Unicode, nullable=False)
    filename = sa.Column(sa.Unicode, nullable=False)
    checksum = sa.Column(sa.Unicode, nullable=False)
    size = sa.Column(sa.Integer, nullable=False)

    def __repr__(self):
        return '<Artifact {} {}>'.format(self.id, self.fingerprint)