Remove annotations

This feature has seen very little adoption and has been a source of
complexity and security issues.
This commit is contained in:
Drew DeVault 2020-06-29 10:20:29 -04:00
parent 1fd577d385
commit d20b49f3ab
4 changed files with 1 additions and 380 deletions

View File

@ -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))

View File

@ -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>",

View File

@ -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")

View File

@ -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;