~edwargix/git.sr.ht

d20b49f3ab4e2ec689d951d72943bf8daa0d9684 — Drew DeVault 5 years ago 1fd577d
Remove annotations

This feature has seen very little adoption and has been a source of
complexity and security issues.
4 files changed, 1 insertions(+), 380 deletions(-)

D gitsrht/annotations.py
M gitsrht/blueprints/api/porcelain.py
M gitsrht/blueprints/repo.py
M scss/main.scss
D gitsrht/annotations.py => gitsrht/annotations.py +0 -285
@@ 1,285 0,0 @@
from pygments.formatter import Formatter
from pygments.token import Token, STANDARD_TYPES
from srht.markdown import markdown
from urllib.parse import urlparse

_escape_html_table = {
    ord('&'): u'&',
    ord('<'): u'&lt;',
    ord('>'): u'&gt;',
    ord('"'): u'&quot;',
    ord("'"): u'&#39;',
}

def escape_html(text, table=_escape_html_table):
    return text.translate(table)

def _get_ttype_class(ttype):
    fname = STANDARD_TYPES.get(ttype)
    if fname:
        return fname
    aname = ''
    while fname is None:
        aname = '-' + ttype[-1] + aname
        ttype = ttype.parent
        fname = STANDARD_TYPES.get(ttype)
    return fname + aname

# Fork of the pygments HtmlFormatter (BSD licensed)
# The main difference is that it relies on AnnotatedFormatter to escape the
# HTML tags in the source. Other features we don't use are removed to keep it
# slim.
class _BaseFormatter(Formatter):
    def __init__(self):
        super().__init__()
        self._create_stylesheet()

    def get_style_defs(self, arg=None):
        """
        Return CSS style definitions for the classes produced by the current
        highlighting style. ``arg`` can be a string or list of selectors to
        insert before the token type classes.
        """
        if arg is None:
            arg = ".highlight"
        if isinstance(arg, str):
            args = [arg]
        else:
            args = list(arg)

        def prefix(cls):
            if cls:
                cls = '.' + cls
            tmp = []
            for arg in args:
                tmp.append((arg and arg + ' ' or '') + cls)
            return ', '.join(tmp)

        styles = [(level, ttype, cls, style)
                  for cls, (style, ttype, level) in self.class2style.items()
                  if cls and style]
        styles.sort()
        lines = ['%s { %s } /* %s */' % (prefix(cls), style, repr(ttype)[6:])
                 for (level, ttype, cls, style) in styles]
        return '\n'.join(lines)

    def _get_css_class(self, ttype):
        """Return the css class of this token type prefixed with
        the classprefix option."""
        ttypeclass = _get_ttype_class(ttype)
        if ttypeclass:
            return ttypeclass
        return ''

    def _get_css_classes(self, ttype):
        """Return the css classes of this token type prefixed with
        the classprefix option."""
        cls = self._get_css_class(ttype)
        while ttype not in STANDARD_TYPES:
            ttype = ttype.parent
            cls = self._get_css_class(ttype) + ' ' + cls
        return cls

    def _create_stylesheet(self):
        t2c = self.ttype2class = {Token: ''}
        c2s = self.class2style = {}
        for ttype, ndef in self.style:
            name = self._get_css_class(ttype)
            style = ''
            if ndef['color']:
                style += 'color: #%s; ' % ndef['color']
            if ndef['bold']:
                style += 'font-weight: bold; '
            if ndef['italic']:
                style += 'font-style: italic; '
            if ndef['underline']:
                style += 'text-decoration: underline; '
            if ndef['bgcolor']:
                style += 'background-color: #%s; ' % ndef['bgcolor']
            if ndef['border']:
                style += 'border: 1px solid #%s; ' % ndef['border']
            if style:
                t2c[ttype] = name
                # save len(ttype) to enable ordering the styles by
                # hierarchy (necessary for CSS cascading rules!)
                c2s[name] = (style[:-2], ttype, len(ttype))

    def _format_lines(self, tokensource):
        lsep = "\n"
        # for <span style=""> lookup only
        getcls = self.ttype2class.get
        c2s = self.class2style

        lspan = ''
        line = []
        for ttype, value in tokensource:
            cls = self._get_css_classes(ttype)
            cspan = cls and '<span class="%s">' % cls or ''

            parts = value.split('\n')

            # for all but the last line
            for part in parts[:-1]:
                if line:
                    if lspan != cspan:
                        line.extend(((lspan and '</span>'), cspan, part,
                                     (cspan and '</span>'), lsep))
                    else:  # both are the same
                        line.extend((part, (lspan and '</span>'), lsep))
                    yield 1, ''.join(line)
                    line = []
                elif part:
                    yield 1, ''.join((cspan, part, (cspan and '</span>'), lsep))
                else:
                    yield 1, lsep
            # for the last line
            if line and parts[-1]:
                if lspan != cspan:
                    line.extend(((lspan and '</span>'), cspan, parts[-1]))
                    lspan = cspan
                else:
                    line.append(parts[-1])
            elif parts[-1]:
                line = [cspan, parts[-1]]
                lspan = cspan
            # else we neither have to open a new span nor set lspan

        if line:
            line.extend(((lspan and '</span>'), lsep))
            yield 1, ''.join(line)

    def _wrap_div(self, inner):
        yield 0, f"<div class='highlight'>"
        for tup in inner:
            yield tup
        yield 0, '</div>\n'

    def _wrap_pre(self, inner):
        yield 0, '<pre><span></span>'
        for tup in inner:
            yield tup
        yield 0, '</pre>'

    def wrap(self, source, outfile):
        """
        Wrap the ``source``, which is a generator yielding
        individual lines, in custom generators. See docstring
        for `format`. Can be overridden.
        """
        return self._wrap_div(self._wrap_pre(source))

    def format_unencoded(self, tokensource, outfile):
        source = self._format_lines(tokensource)
        source = self.wrap(source, outfile)
        for t, piece in source:
            outfile.write(piece)

def validate_annotation(valid, anno):
    valid.expect("type" in anno, "'type' is required")
    if not valid.ok:
        return
    valid.expect(anno["type"] in ["link", "markdown"],
            f"'{anno['type']} is not a valid annotation type'")
    if anno["type"] == "link":
        for field in ["lineno", "colno", "len"]:
            valid.expect(field in anno, "f'{field}' is required")
            valid.expect(field not in anno or isinstance(anno[field], int),
                    "f'{field}' must be an integer")
        valid.expect("to" in anno, "'to' is required")
        valid.expect("title" not in anno or isinstance(anno["title"], str),
                "'title' must be a string")
        valid.expect("color" not in anno or isinstance(anno["color"], str),
                "'color' must be a string")
        if "color" in anno and anno["color"] != "transparent":
            valid.expect("color" not in anno or len(anno["color"]) == 7,
                    "'color' must be a 7 digit string or 'transparent'")
            valid.expect("color" not in anno or not any(
                c for c in anno["color"].lower() if c not in "#0123456789abcdef"),
                    "'color' must be in hexadecimal or 'transparent'")
    elif anno["type"] == "markdown":
        for field in ["lineno"]:
            valid.expect(field in anno, "f'{field}' is required")
            valid.expect(field not in anno or isinstance(anno[field], int),
                    "f'{field}' must be an integer")
        for field in ["title", "content"]:
            valid.expect(field in anno, "f'{field}' is required")
            valid.expect(field not in anno or isinstance(anno[field], str),
                    "f'{field}' must be a string")

class AnnotatedFormatter(_BaseFormatter):
    def __init__(self, get_annos, link_prefix):
        super().__init__()
        self.get_annos = get_annos
        self.link_prefix = link_prefix

    @property
    def annos(self):
        if hasattr(self, "_annos"):
            return self._annos
        self._annos = dict()
        for anno in (self.get_annos() or list()):
            lineno = int(anno["lineno"])
            self._annos.setdefault(lineno, list())
            self._annos[lineno].append(anno)
            self._annos[lineno] = sorted(self._annos[lineno],
                    key=lambda anno: anno.get("from", -1))
        return self._annos

    def _annotate_token(self, token, colno, annos):
        # TODO: Extend this to support >1 anno per token
        for anno in annos:
            if anno["type"] == "link":
                start = anno["colno"] - 1
                end = anno["colno"] + anno["len"] - 1
                target = anno["to"]
                title = anno.get("title", "")
                color = anno.get("color", None)
                url = urlparse(target)
                if url.scheme == "":
                    target = self.link_prefix + "/" + target
                if start <= colno < end:
                    if color is not None:
                        return (f"<a class='annotation' title='{escape_html(title)}' " +
                            f"href='{escape_html(target)}' " +
                            f"rel='nofollow noopener' " +
                            f"style='background-color: {color}' " +
                            f">{escape_html(token)}</a>""")
                    else:
                        return (f"<a class='annotation' title='{escape_html(title)}' " +
                            f"href='{escape_html(target)}' " +
                            f"rel='nofollow noopener' " +
                            f">{escape_html(token)}</a>""")
            elif anno["type"] == "markdown":
                if "\n" not in token:
                    continue
                title = anno["title"]
                content = anno["content"]
                content = markdown(content, baselevel=6,
                        link_prefix=self.link_prefix)
                annotation = f"<details><summary>{escape_html(title)}</summary>{content}</details>\n"
                token = escape_html(token).replace("\n", annotation, 1)
                return token
            # Other types?
        return escape_html(token)

    def _wrap_source(self, source):
        lineno = 0
        colno = 0
        for ttype, token in source:
            parts = token.splitlines(True)
            _lineno = lineno
            for part in parts:
                annos = self.annos.get(_lineno + 1, [])
                if any(annos):
                    yield ttype, self._annotate_token(part, colno, annos)
                else:
                    yield ttype, escape_html(part)
                _lineno += 1
            if "\n" in token:
                lineno += sum(1 if c == "\n" else 0 for c in token)
                colno = len(token[token.rindex("\n")+1:])
            else:
                colno += len(token)

    def _format_lines(self, source):
        yield from super()._format_lines(self._wrap_source(source))

M gitsrht/blueprints/api/porcelain.py => gitsrht/blueprints/api/porcelain.py +0 -46
@@ 2,7 2,6 @@ import base64
import json
import pygit2
from flask import Blueprint, current_app, request, send_file, abort
from gitsrht.annotations import validate_annotation
from gitsrht.blueprints.repo import lookup_ref, collect_refs
from gitsrht.types import Artifact
from gitsrht.git import Repository as GitRepository, commit_time, annotate_tree


@@ 200,51 199,6 @@ def repo_tree_GET(username, reponame, ref, path):
            abort(404)
        return tree_to_dict(tree)

# TODO: remove fallback routes
@porcelain.route("/api/repos/<reponame>/annotate", methods=["PUT"],
        defaults={"username": None, "commit": "master"})
@porcelain.route("/api/<username>/repos/<reponame>/annotate", methods=["PUT"],
        defaults={"commit": "master"})
@porcelain.route("/api/repos/<reponame>/<commit>/annotate", methods=["PUT"],
        defaults={"username": None})
@porcelain.route("/api/<username>/repos/<reponame>/<commit>/annotate", methods=["PUT"])
@oauth("repo:write")
def repo_annotate_PUT(username, reponame, commit):
    user = get_user(username)
    repo = get_repo(user, reponame, needs=UserAccess.manage)

    valid = Validation(request)

    with GitRepository(repo.path) as git_repo:
        try:
            commit = git_repo.revparse_single(commit)
        except KeyError:
            abort(404)
        except ValueError:
            abort(404)
        if not isinstance(commit, pygit2.Commit):
            abort(400)
        commit = commit.id.hex

    nblobs = 0
    for oid, annotations in valid.source.items():
        valid.expect(isinstance(oid, str), "blob keys must be strings")
        valid.expect(isinstance(annotations, list),
                "blob values must be lists of annotations")
        if not valid.ok:
            return valid.response
        for anno in annotations:
            validate_annotation(valid, anno)
        if not valid.ok:
            return valid.response
        redis.set(f"git.sr.ht:git:annotations:{repo.id}:{oid}:{commit}",
                json.dumps(annotations))
        # Invalidate rendered markup cache
        redis.delete(f"git.sr.ht:git:highlight:{oid}")
        nblobs += 1

    return { "updated": nblobs }, 200

@porcelain.route("/api/repos/<reponame>/blob/<path:ref>",
        defaults={"username": None, "path": ""})
@porcelain.route("/api/repos/<reponame>/blob/<ref>/<path:path>",

M gitsrht/blueprints/repo.py => gitsrht/blueprints/repo.py +1 -9
@@ 8,7 8,6 @@ import sys
from datetime import timedelta
from flask import Blueprint, render_template, abort, send_file, request
from flask import Response, url_for, session, redirect
from gitsrht.annotations import AnnotatedFormatter
from gitsrht.editorconfig import EditorConfig
from gitsrht.git import Repository as GitRepository, commit_time, annotate_tree
from gitsrht.git import diffstat, get_log


@@ 53,16 52,9 @@ def get_readme(repo, tip, link_prefix=None):
            link_prefix=link_prefix)

def _highlight_file(repo, ref, name, data, blob_id, commit_id):
    def get_annos():
        annotations = redis.get("git.sr.ht:git:annotations:" +
            f"{repo.id}:{blob_id}:{commit_id}")
        if annotations:
            return json.loads(annotations.decode())
        return None
    link_prefix = url_for('repo.tree', owner=repo.owner,
            repo=repo.name, ref=ref)
    return get_highlighted_file("git.sr.ht:git", name, blob_id, data,
            formatter=AnnotatedFormatter(get_annos, link_prefix))
    return get_highlighted_file("git.sr.ht:git", name, blob_id, data)

def render_empty_repo(owner, repo):
    origin = cfg("git.sr.ht", "origin")

M scss/main.scss => scss/main.scss +0 -40
@@ 176,46 176,6 @@ img {
  max-width: 100%;
}

// Annotations
.highlight {
  a.annotation {
    color: inherit;
    background: lighten($primary, 45);
    border-bottom: 1px dotted $gray-800;
    text-decoration: none;

    &:hover {
      text-decoration: none;
      border-bottom: 1px solid $gray-800;
    }
  }

  details {
    display: inline;
    margin-left: 3rem;
    color: $gray-600;

    &[open] {
      display: block;
      color: inherit;
      background: $gray-100;
      width: 30rem;
      margin: 0;
      white-space: normal;
      font: inherit;
      position: absolute;

      summary {
        background: $gray-300;
      }

      ul:last-child {
        margin-bottom: 0;
      }
    }
  }
}

.prepare-patchset {
  legend {
    font-weight: bold;