Remove annotations
This feature has seen very little adoption and has been a source of complexity and security issues.
This commit is contained in:
parent
1fd577d385
commit
d20b49f3ab
|
@ -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'<',
|
||||
ord('>'): u'>',
|
||||
ord('"'): u'"',
|
||||
ord("'"): u''',
|
||||
}
|
||||
|
||||
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))
|
|
@ -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>",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue