init: basic protocol/nav/rendering
This commit is contained in:
commit
6d676d0471
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
/venv/
|
0
bebop/__init__.py
Normal file
0
bebop/__init__.py
Normal file
23
bebop/__main__.py
Normal file
23
bebop/__main__.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from bebop.tofu import load_cert_stash
|
||||||
|
from bebop.screen import Screen
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argparser = argparse.ArgumentParser()
|
||||||
|
argparser.add_argument("url", default=None)
|
||||||
|
args = argparser.parse_args()
|
||||||
|
|
||||||
|
if args.url:
|
||||||
|
start_url = args.url
|
||||||
|
if not start_url.startswith("gemini://"):
|
||||||
|
start_url = "gemini://" + start_url
|
||||||
|
else:
|
||||||
|
start_url = None
|
||||||
|
|
||||||
|
cert_stash = load_cert_stash("/tmp/stash")
|
||||||
|
Screen(cert_stash).run(start_url=start_url)
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
29
bebop/colors.py
Normal file
29
bebop/colors.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import curses
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
class ColorPair(IntEnum):
|
||||||
|
NORMAL = 0
|
||||||
|
ERROR = 1
|
||||||
|
LINK = 2
|
||||||
|
LINK_ID = 3
|
||||||
|
TITLE_1 = 4
|
||||||
|
TITLE_2 = 5
|
||||||
|
TITLE_3 = 6
|
||||||
|
PREFORMATTED = 7
|
||||||
|
BLOCKQUOTE = 8
|
||||||
|
DEBUG = 99
|
||||||
|
|
||||||
|
|
||||||
|
def init_colors():
|
||||||
|
curses.use_default_colors()
|
||||||
|
curses.init_pair(ColorPair.NORMAL, curses.COLOR_WHITE, -1)
|
||||||
|
curses.init_pair(ColorPair.ERROR, curses.COLOR_RED, -1)
|
||||||
|
curses.init_pair(ColorPair.LINK, curses.COLOR_CYAN, -1)
|
||||||
|
curses.init_pair(ColorPair.LINK_ID, curses.COLOR_WHITE, -1)
|
||||||
|
curses.init_pair(ColorPair.TITLE_1, curses.COLOR_GREEN, -1)
|
||||||
|
curses.init_pair(ColorPair.TITLE_2, curses.COLOR_MAGENTA, -1)
|
||||||
|
curses.init_pair(ColorPair.TITLE_3, curses.COLOR_MAGENTA, -1)
|
||||||
|
curses.init_pair(ColorPair.PREFORMATTED, curses.COLOR_YELLOW, -1)
|
||||||
|
curses.init_pair(ColorPair.BLOCKQUOTE, curses.COLOR_CYAN, -1)
|
||||||
|
curses.init_pair(ColorPair.DEBUG, curses.COLOR_BLACK, curses.COLOR_GREEN)
|
79
bebop/gemtext.py
Normal file
79
bebop/gemtext.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import re
|
||||||
|
import typing
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Paragraph:
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Title:
|
||||||
|
level: int
|
||||||
|
text: str
|
||||||
|
RE = re.compile(r"(#{1,3})\s+(.+)")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Link:
|
||||||
|
url: str
|
||||||
|
text: str
|
||||||
|
RE = re.compile(r"=>\s*(?P<url>\S+)(\s+(?P<text>.+))?")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Preformatted:
|
||||||
|
lines: typing.List[str]
|
||||||
|
FENCE = "```"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Blockquote:
|
||||||
|
text: str
|
||||||
|
RE = re.compile(r">\s*(.*)")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gemtext(data):
|
||||||
|
"""Parse UTF-8 encoded Gemtext as a list of elements."""
|
||||||
|
text = data.decode(encoding="utf8", errors="ignore")
|
||||||
|
elements = []
|
||||||
|
preformatted = None
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.rstrip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = Title.RE.match(line)
|
||||||
|
if match:
|
||||||
|
hashtags, text = match.groups()
|
||||||
|
elements.append(Title(hashtags.count("#"), text))
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = Link.RE.match(line)
|
||||||
|
if match:
|
||||||
|
match_dict = match.groupdict()
|
||||||
|
url, text = match_dict["url"], match_dict.get("text", "")
|
||||||
|
elements.append(Link(url, text))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line == Preformatted.FENCE:
|
||||||
|
if preformatted:
|
||||||
|
elements.append(preformatted)
|
||||||
|
preformatted = None
|
||||||
|
else:
|
||||||
|
preformatted = Preformatted([])
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = Blockquote.RE.match(line)
|
||||||
|
if match:
|
||||||
|
text = match.groups()[0]
|
||||||
|
elements.append(Blockquote(text))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if preformatted:
|
||||||
|
preformatted.lines.append(line)
|
||||||
|
else:
|
||||||
|
elements.append(Paragraph(line))
|
||||||
|
|
||||||
|
return elements
|
28
bebop/mouse.py
Normal file
28
bebop/mouse.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonState(IntEnum):
|
||||||
|
"""Most common flags from curses.getmouse()'s bstate.
|
||||||
|
|
||||||
|
Could not find a clear reference for that and released/pressed seem inverted
|
||||||
|
compared to snippets on the Web, so take portability with a grain of salt.
|
||||||
|
"""
|
||||||
|
LEFT_RELEASED = 1 << 0
|
||||||
|
LEFT_PRESSED = 1 << 1
|
||||||
|
LEFT_CLICKED = 1 << 2
|
||||||
|
LEFT_DCLICKED = 1 << 3
|
||||||
|
LEFT_TCLICKED = 1 << 4
|
||||||
|
MIDDLE_RELEASED = 1 << 5
|
||||||
|
MIDDLE_PRESSED = 1 << 6
|
||||||
|
MIDDLE_CLICKED = 1 << 7
|
||||||
|
MIDDLE_DCLICKED = 1 << 8
|
||||||
|
MIDDLE_TCLICKED = 1 << 9
|
||||||
|
RIGHT_RELEASED = 1 << 10
|
||||||
|
RIGHT_PRESSED = 1 << 11
|
||||||
|
RIGHT_CLICKED = 1 << 12
|
||||||
|
RIGHT_DCLICKED = 1 << 13
|
||||||
|
RIGHT_TCLICKED = 1 << 14
|
||||||
|
SCROLL_UP = 1 << 16
|
||||||
|
SCROLL_DOWN = 1 << 21
|
||||||
|
SHIFT = 1 << 25
|
||||||
|
CTRL = 1 << 26
|
34
bebop/navigation.py
Normal file
34
bebop/navigation.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
|
||||||
|
def parse_url(url, absolute=False):
|
||||||
|
"""Return URL parts from this URL.
|
||||||
|
|
||||||
|
This uses urllib.parse.urlparse to not reinvent the wheel, with a few
|
||||||
|
adjustments.
|
||||||
|
|
||||||
|
First, urllib does not know the Gemini scheme (yet!) so if it
|
||||||
|
is specified we strip it to get an absolute netloc.
|
||||||
|
|
||||||
|
Second, as this function can be used to process arbitrary user input, we
|
||||||
|
clean it a bit:
|
||||||
|
- strip whitespaces from the URL
|
||||||
|
- if "absolute" is True, consider that the URL is meant to be absolute, even
|
||||||
|
though it technically is not, e.g. "dece.space" is not absolute as it
|
||||||
|
misses either the // delimiter.
|
||||||
|
"""
|
||||||
|
if url.startswith("gemini://"):
|
||||||
|
url = url[7:]
|
||||||
|
parts = urllib.parse.urlparse(url, scheme="gemini")
|
||||||
|
if not parts.netloc and absolute:
|
||||||
|
parts = urllib.parse.urlparse(f"//{url}", scheme="gemini")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def join_url(base_url, url):
|
||||||
|
"""Join a base URL with a relative url."""
|
||||||
|
if base_url.startswith("gemini://"):
|
||||||
|
base_url = base_url[7:]
|
||||||
|
parts = parse_url(urllib.parse.urljoin(base_url, url))
|
||||||
|
return urllib.parse.urlunparse(parts)
|
175
bebop/protocol.py
Normal file
175
bebop/protocol.py
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from bebop.tofu import CertStatus, CERT_STATUS_INVALID, validate_cert
|
||||||
|
|
||||||
|
|
||||||
|
GEMINI_URL_RE = re.compile(r"gemini://(?P<host>[^/]+)(?P<path>.*)")
|
||||||
|
LINE_TERM = b"\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gemini_url(url):
|
||||||
|
"""Return a dict containing the hostname and the request path, or None."""
|
||||||
|
match = GEMINI_URL_RE.match(url)
|
||||||
|
return match.groupdict() if match else None
|
||||||
|
|
||||||
|
|
||||||
|
class Request:
|
||||||
|
"""A Gemini request."""
|
||||||
|
|
||||||
|
# Initial state, connection is not established yet.
|
||||||
|
STATE_INIT = 0
|
||||||
|
# An error has occured during cert verification, connection is aborted.
|
||||||
|
STATE_ERROR_CERT = 1
|
||||||
|
# An invalid URL has been provided, connection is aborted.
|
||||||
|
STATE_INVALID_URL = 2
|
||||||
|
# Invalid cert: user should abort or temporarily trust the cert.
|
||||||
|
STATE_INVALID_CERT = 3
|
||||||
|
# Unknown cert: user should abort, temporarily or always trust the cert.
|
||||||
|
STATE_UNKNOWN_CERT = 4
|
||||||
|
# Untrusted cert: connection is aborted, manually edit the stash.
|
||||||
|
STATE_UNTRUSTED_CERT = 5
|
||||||
|
# Valid and trusted cert: proceed.
|
||||||
|
STATE_OK = 6
|
||||||
|
|
||||||
|
def __init__(self, url, cert_stash):
|
||||||
|
self.url = url
|
||||||
|
self.cert_stash = cert_stash
|
||||||
|
self.state = Request.STATE_INIT
|
||||||
|
self.payload = b""
|
||||||
|
self.ssock = None
|
||||||
|
self.cert = None
|
||||||
|
self.cert_status = None
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Connect to a Gemini server and return a RequestEventType.
|
||||||
|
|
||||||
|
Return True if the connection is established. The caller has to verify
|
||||||
|
the request state and propose appropriate choices to the user if the
|
||||||
|
certificate status is not CertStatus.VALID (Request.STATE_OK).
|
||||||
|
|
||||||
|
If connect returns False, the secure socket is aborted before return. If
|
||||||
|
connect returns True, it is up to the caller to decide whether to
|
||||||
|
continue (call proceed) the connection or abort it (call abort).
|
||||||
|
"""
|
||||||
|
url_parts = parse_gemini_url(self.url)
|
||||||
|
if not url_parts:
|
||||||
|
self.state = Request.STATE_INVALID_URL
|
||||||
|
return False
|
||||||
|
hostname = url_parts["host"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.payload = self.url.encode()
|
||||||
|
except ValueError:
|
||||||
|
self.state = Request.STATE_INVALID_URL
|
||||||
|
return False
|
||||||
|
self.payload += LINE_TERM
|
||||||
|
|
||||||
|
context = Request.get_ssl_context()
|
||||||
|
sock = socket.create_connection((hostname, 1965))
|
||||||
|
self.ssock = context.wrap_socket(sock)
|
||||||
|
der = self.ssock.getpeercert(binary_form=True)
|
||||||
|
self.cert_status, self.cert = \
|
||||||
|
validate_cert(der, hostname, self.cert_stash)
|
||||||
|
if self.cert_status == CertStatus.ERROR:
|
||||||
|
self.abort()
|
||||||
|
self.state = Request.STATE_ERROR_CERT
|
||||||
|
return False
|
||||||
|
if self.cert_status == CertStatus.WRONG_FINGERPRINT:
|
||||||
|
self.abort()
|
||||||
|
self.state = Request.STATE_UNTRUSTED_CERT
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.cert_status in CERT_STATUS_INVALID:
|
||||||
|
self.state = Request.STATE_INVALID_CERT
|
||||||
|
elif self.cert_status == CertStatus.VALID_NEW:
|
||||||
|
self.state = Request.STATE_UNKNOWN_CERT
|
||||||
|
else: # self.cert_status == CertStatus.VALID
|
||||||
|
self.state = Request.STATE_OK
|
||||||
|
return True
|
||||||
|
|
||||||
|
def abort(self):
|
||||||
|
"""Close the connection."""
|
||||||
|
self.ssock.close()
|
||||||
|
|
||||||
|
def proceed(self):
|
||||||
|
"""Complete the request: send the payload and return received data."""
|
||||||
|
self.ssock.sendall(self.payload)
|
||||||
|
response = b""
|
||||||
|
while True:
|
||||||
|
buf = self.ssock.recv(4096)
|
||||||
|
if not buf:
|
||||||
|
return response
|
||||||
|
response += buf
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_ssl_context():
|
||||||
|
"""Return a secure SSL context that is adequate for Gemini."""
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
context.options |= ssl.OP_NO_TLSv1
|
||||||
|
context.options |= ssl.OP_NO_TLSv1_1
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class StatusCode(IntEnum):
|
||||||
|
UNKNOWN = 0
|
||||||
|
INPUT = 10
|
||||||
|
SENSITIVE_INPUT = 11
|
||||||
|
SUCCESS = 20
|
||||||
|
REDIRECT = 30
|
||||||
|
PERMANENT_REDIRECT = 31
|
||||||
|
TEMP_FAILURE = 40
|
||||||
|
SERVER_UNAVAILABLE = 41
|
||||||
|
CGI_ERROR = 42
|
||||||
|
PROXY_ERROR = 43
|
||||||
|
SLOW_DOWN = 44
|
||||||
|
PERM_FAILURE = 50
|
||||||
|
NOT_FOUND = 51
|
||||||
|
GONE = 52
|
||||||
|
PROXY_REQUEST_REFUSED = 53
|
||||||
|
BAD_REQUEST = 59
|
||||||
|
CERT_REQUIRED = 60
|
||||||
|
CERT_NOT_AUTHORISED = 61
|
||||||
|
CERT_NOT_VALID = 62
|
||||||
|
_missing_ = lambda _: StatusCode.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Response:
|
||||||
|
"""A Gemini response."""
|
||||||
|
|
||||||
|
code: StatusCode
|
||||||
|
meta: str = ""
|
||||||
|
content: bytes = b""
|
||||||
|
|
||||||
|
HEADER_RE = re.compile(r"(\d{2}) (\S*)")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(data):
|
||||||
|
"""Parse a received response."""
|
||||||
|
try:
|
||||||
|
response_header_len = data.index(LINE_TERM)
|
||||||
|
response_header = data[:response_header_len].decode()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
match = Response.HEADER_RE.match(response_header)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
code, meta = match.groups()
|
||||||
|
response = Response(StatusCode(code), meta=meta)
|
||||||
|
if Response.get_generic_code(response.code) == StatusCode.SUCCESS:
|
||||||
|
content_offset = response_header_len + len(LINE_TERM)
|
||||||
|
response.content = data[content_offset:]
|
||||||
|
elif response.code == StatusCode.UNKNOWN:
|
||||||
|
return None
|
||||||
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_generic_code(code):
|
||||||
|
"""Return the generic version (x0) of this code."""
|
||||||
|
return code - (code % 10)
|
229
bebop/rendering.py
Normal file
229
bebop/rendering.py
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
import curses
|
||||||
|
import string
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from bebop.colors import ColorPairs
|
||||||
|
from bebop.gemtext import Blockquote, Link, Paragraph, Preformatted, Title
|
||||||
|
|
||||||
|
|
||||||
|
SPLIT_CHARS = " \t-"
|
||||||
|
JOIN_CHAR = "-"
|
||||||
|
|
||||||
|
|
||||||
|
class LineType(IntEnum):
|
||||||
|
"""Type of line.
|
||||||
|
|
||||||
|
Keep lines type along with the content for later rendering.
|
||||||
|
Title type values match the title level to avoid looking it up.
|
||||||
|
"""
|
||||||
|
NONE = 0
|
||||||
|
TITLE_1 = 1
|
||||||
|
TITLE_2 = 2
|
||||||
|
TITLE_3 = 3
|
||||||
|
PARAGRAPH = 4
|
||||||
|
LINK = 5
|
||||||
|
PREFORMATTED = 6
|
||||||
|
BLOCKQUOTE = 7
|
||||||
|
|
||||||
|
|
||||||
|
def format_elements(elements, width):
|
||||||
|
"""Format elements into a list of lines with metadata.
|
||||||
|
|
||||||
|
The returned list ("metalines") are tuples (meta, line), meta being a
|
||||||
|
dict of metadata and line a text line to display. Currently the only
|
||||||
|
metadata keys used are:
|
||||||
|
- type: one of the Renderer.TYPE constants.
|
||||||
|
- url: only for links, the URL the link on this line refers to. Note
|
||||||
|
that this key is present only for the first line of the link, i.e.
|
||||||
|
long link descriptions wrapped on multiple lines will not have a this
|
||||||
|
key except for the first line.
|
||||||
|
- link_id: only alongside "url" key, ID generated for this link.
|
||||||
|
"""
|
||||||
|
metalines = []
|
||||||
|
context = {"last_link_id": 0, "width": width}
|
||||||
|
separator = ({"type": LineType.NONE}, "")
|
||||||
|
has_margins = False
|
||||||
|
for index, element in enumerate(elements):
|
||||||
|
previous_had_margins = has_margins
|
||||||
|
has_margins = False
|
||||||
|
if isinstance(element, Title):
|
||||||
|
element_metalines = format_title(element, context)
|
||||||
|
has_margins = True
|
||||||
|
elif isinstance(element, Paragraph):
|
||||||
|
element_metalines = format_paragraph(element, context)
|
||||||
|
has_margins = True
|
||||||
|
elif isinstance(element, Link):
|
||||||
|
element_metalines = format_link(element, context)
|
||||||
|
elif isinstance(element, Preformatted):
|
||||||
|
element_metalines = format_preformatted(element, context)
|
||||||
|
has_margins = True
|
||||||
|
elif isinstance(element, Blockquote):
|
||||||
|
element_metalines = format_blockquote(element, context)
|
||||||
|
has_margins = True
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
# If current element requires margins and is not the first elements,
|
||||||
|
# separate from previous element. Also do it if the current element does
|
||||||
|
# not require margins but follows an element that required it (e.g. link
|
||||||
|
# after a paragraph).
|
||||||
|
if (
|
||||||
|
(has_margins and index > 0)
|
||||||
|
or (not has_margins and previous_had_margins)
|
||||||
|
):
|
||||||
|
metalines.append(separator)
|
||||||
|
metalines += element_metalines
|
||||||
|
return metalines
|
||||||
|
|
||||||
|
|
||||||
|
def format_title(title: Title, context: dict):
|
||||||
|
"""Return metalines for this title."""
|
||||||
|
if title.level == 1:
|
||||||
|
wrapped = wrap_words(title.text, context["width"])
|
||||||
|
line_template = f"{{:^{context['width']}}}"
|
||||||
|
lines = (line_template.format(line) for line in wrapped)
|
||||||
|
else:
|
||||||
|
if title.level == 2:
|
||||||
|
text = title.text
|
||||||
|
else:
|
||||||
|
text = " " + title.text
|
||||||
|
lines = wrap_words(text, context["width"])
|
||||||
|
# Title levels match the type constants of titles.
|
||||||
|
return [({"type": LineType(title.level)}, line) for line in lines]
|
||||||
|
|
||||||
|
|
||||||
|
def format_paragraph(paragraph: Paragraph, context: dict):
|
||||||
|
"""Return metalines for this paragraph."""
|
||||||
|
lines = wrap_words(paragraph.text, context["width"])
|
||||||
|
return [({"type": LineType.PARAGRAPH}, line) for line in lines]
|
||||||
|
|
||||||
|
|
||||||
|
def format_link(link: Link, context: dict):
|
||||||
|
"""Return metalines for this link."""
|
||||||
|
link_id = context["last_link_id"] + 1
|
||||||
|
context["last_link_id"] = link_id
|
||||||
|
link_text = link.text or link.url
|
||||||
|
text = f"[{link_id}] " + link_text
|
||||||
|
lines = wrap_words(text, context["width"])
|
||||||
|
first_line_meta = {
|
||||||
|
"type": LineType.LINK,
|
||||||
|
"url": link.url,
|
||||||
|
"link_id": link_id
|
||||||
|
}
|
||||||
|
first_line = [(first_line_meta, lines[0])]
|
||||||
|
other_lines = [({"type": LineType.LINK}, line) for line in lines[1:]]
|
||||||
|
return first_line + other_lines
|
||||||
|
|
||||||
|
|
||||||
|
def format_preformatted(preformatted: Preformatted, context: dict):
|
||||||
|
"""Return metalines for this preformatted block."""
|
||||||
|
return [
|
||||||
|
({"type": LineType.PREFORMATTED}, line)
|
||||||
|
for line in preformatted.lines
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def format_blockquote(blockquote: Blockquote, context: dict):
|
||||||
|
"""Return metalines for this blockquote."""
|
||||||
|
lines = wrap_words(blockquote.text, context["width"])
|
||||||
|
return [({"type": LineType.BLOCKQUOTE}, line) for line in lines]
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_words(text, width):
|
||||||
|
"""Wrap a text in several lines according to the renderer's width."""
|
||||||
|
lines = []
|
||||||
|
line = ""
|
||||||
|
words = _explode_words(text)
|
||||||
|
for word in words:
|
||||||
|
line_len, word_len = len(line), len(word)
|
||||||
|
# If adding the new word would overflow the line, use a new line.
|
||||||
|
if line_len + word_len > width:
|
||||||
|
# Push only non-empty lines.
|
||||||
|
if line_len > 0:
|
||||||
|
lines.append(line)
|
||||||
|
line = ""
|
||||||
|
# Force split words that are longer than the width.
|
||||||
|
while word_len > width:
|
||||||
|
lines.append(word[:width - 1] + JOIN_CHAR)
|
||||||
|
word = word[width - 1:]
|
||||||
|
word_len = len(word)
|
||||||
|
word = word.lstrip()
|
||||||
|
line += word
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _explode_words(text):
|
||||||
|
words = []
|
||||||
|
pos = 0
|
||||||
|
while True:
|
||||||
|
sep, sep_index = _find_next_sep(text[pos:])
|
||||||
|
if not sep:
|
||||||
|
words.append(text[pos:])
|
||||||
|
return words
|
||||||
|
word = text[pos : pos + sep_index]
|
||||||
|
# If the separator is not a space char, append it to the word.
|
||||||
|
if sep in string.whitespace:
|
||||||
|
words.append(word)
|
||||||
|
words.append(sep)
|
||||||
|
else:
|
||||||
|
words.append(word + sep)
|
||||||
|
pos += sep_index + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _find_next_sep(text):
|
||||||
|
indices = []
|
||||||
|
for sep in SPLIT_CHARS:
|
||||||
|
try:
|
||||||
|
indices.append((sep, text.index(sep)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if not indices:
|
||||||
|
return ("", 0)
|
||||||
|
return min(indices, key=lambda e: e[1])
|
||||||
|
|
||||||
|
|
||||||
|
def render_lines(metalines, window, max_width):
|
||||||
|
"""Write a list of metalines in window.
|
||||||
|
|
||||||
|
As this function does not know about the window/pad previous size, it
|
||||||
|
expects a cleared window, especially if the new content is shorter than the
|
||||||
|
previous one: merely clearing after the resize will not remove artefacts.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
- metalines: list of metalines to render, must have at least one element.
|
||||||
|
- window: window that will be resized as filled with rendered lines.
|
||||||
|
- max_width: line length limit for the pad.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
The tuple (height, width) of the resized window.
|
||||||
|
"""
|
||||||
|
num_lines = len(metalines)
|
||||||
|
window.resize(num_lines, max_width)
|
||||||
|
for line_index, metaline in enumerate(metalines):
|
||||||
|
meta, line = metaline
|
||||||
|
line = line[:max_width - 1]
|
||||||
|
line_type = meta["type"]
|
||||||
|
if line_type == LineType.TITLE_1:
|
||||||
|
attributes = curses.color_pair(ColorPairs.TITLE_1) | curses.A_BOLD
|
||||||
|
window.addstr(line, attributes)
|
||||||
|
elif line_type == LineType.TITLE_2:
|
||||||
|
attributes = curses.color_pair(ColorPairs.TITLE_2) | curses.A_BOLD
|
||||||
|
window.addstr(line, attributes)
|
||||||
|
elif line_type == LineType.TITLE_3:
|
||||||
|
window.addstr(line, curses.color_pair(ColorPairs.TITLE_3))
|
||||||
|
elif line_type == LineType.LINK:
|
||||||
|
window.addstr(line, curses.color_pair(ColorPairs.LINK))
|
||||||
|
elif line_type == LineType.PREFORMATTED:
|
||||||
|
window.addstr(line, curses.color_pair(ColorPairs.PREFORMATTED))
|
||||||
|
elif line_type == LineType.BLOCKQUOTE:
|
||||||
|
attributes = (
|
||||||
|
curses.color_pair(ColorPairs.BLOCKQUOTE)
|
||||||
|
| curses.A_ITALIC
|
||||||
|
)
|
||||||
|
window.addstr(line, attributes)
|
||||||
|
else: # includes LineType.PARAGRAPH
|
||||||
|
window.addstr(line)
|
||||||
|
if line_index < num_lines - 1:
|
||||||
|
window.addstr("\n")
|
||||||
|
return num_lines, max_width
|
425
bebop/screen.py
Normal file
425
bebop/screen.py
Normal file
|
@ -0,0 +1,425 @@
|
||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
import curses.textpad
|
||||||
|
import os
|
||||||
|
|
||||||
|
from bebop.colors import ColorPair, init_colors
|
||||||
|
from bebop.gemtext import parse_gemtext
|
||||||
|
from bebop.mouse import ButtonState
|
||||||
|
from bebop.navigation import join_url, parse_url
|
||||||
|
from bebop.protocol import Request, Response
|
||||||
|
from bebop.rendering import format_elements, render_lines
|
||||||
|
|
||||||
|
|
||||||
|
class Screen:
|
||||||
|
|
||||||
|
MAX_COLS = 1000
|
||||||
|
|
||||||
|
def __init__(self, cert_stash):
|
||||||
|
self.stash = cert_stash
|
||||||
|
self.screen = None
|
||||||
|
self.dim = (0, 0)
|
||||||
|
self.content_pad = None
|
||||||
|
self.content_pad_dim = (0, 0)
|
||||||
|
self.status_window = None
|
||||||
|
self.command_window = None
|
||||||
|
self.command_textbox = None
|
||||||
|
self.metalines = []
|
||||||
|
self.current_url = ""
|
||||||
|
self.current_line = 0
|
||||||
|
self.current_column = 0
|
||||||
|
self.links = {}
|
||||||
|
self.status_data = ("", 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def h(self):
|
||||||
|
return self.dim[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def w(self):
|
||||||
|
return self.dim[1]
|
||||||
|
|
||||||
|
def run(self, *args, **kwargs):
|
||||||
|
"""Use curses' wrapper around _run."""
|
||||||
|
os.environ.setdefault("ESCDELAY", "25")
|
||||||
|
curses.wrapper(self._run, *args, **kwargs)
|
||||||
|
|
||||||
|
def _run(self, stdscr, start_url=None):
|
||||||
|
"""Start displaying content and handling events."""
|
||||||
|
self.screen = stdscr
|
||||||
|
self.screen.clear()
|
||||||
|
self.screen.refresh()
|
||||||
|
init_colors()
|
||||||
|
curses.mousemask(curses.ALL_MOUSE_EVENTS)
|
||||||
|
|
||||||
|
self.dim = self.screen.getmaxyx()
|
||||||
|
self.content_pad_dim = (self.h - 2, Screen.MAX_COLS)
|
||||||
|
self.content_pad = curses.newpad(*self.content_pad_dim)
|
||||||
|
self.content_pad.scrollok(True)
|
||||||
|
self.content_pad.idlok(True)
|
||||||
|
self.status_window = self.screen.subwin(
|
||||||
|
*self.line_dim,
|
||||||
|
*self.status_window_pos,
|
||||||
|
)
|
||||||
|
self.command_window = self.screen.subwin(
|
||||||
|
*self.line_dim,
|
||||||
|
*self.command_window_pos,
|
||||||
|
)
|
||||||
|
curses.curs_set(0)
|
||||||
|
|
||||||
|
pending_url = start_url
|
||||||
|
running = True
|
||||||
|
while running:
|
||||||
|
if pending_url:
|
||||||
|
self.open_gemini_url(pending_url)
|
||||||
|
pending_url = None
|
||||||
|
|
||||||
|
char = self.screen.getch()
|
||||||
|
if char == ord("q"):
|
||||||
|
running = False
|
||||||
|
elif char == ord(":"):
|
||||||
|
command = self.input_common_command()
|
||||||
|
self.set_status(f"Command: {command}")
|
||||||
|
elif char == ord("s"):
|
||||||
|
self.set_status(f"h {self.h} w {self.w} cl {self.current_line} cc {self.current_column}")
|
||||||
|
elif char == ord("r"):
|
||||||
|
self.refresh_content()
|
||||||
|
elif char == ord("h"):
|
||||||
|
if self.current_column > 0:
|
||||||
|
self.current_column -= 1
|
||||||
|
self.refresh_content()
|
||||||
|
elif char == ord("j"):
|
||||||
|
self.scroll_content(1)
|
||||||
|
elif char == ord("k"):
|
||||||
|
self.scroll_content(1, scroll_up=True)
|
||||||
|
elif char == ord("l"):
|
||||||
|
if self.current_column < Screen.MAX_COLS - self.w:
|
||||||
|
self.current_column += 1
|
||||||
|
self.refresh_content()
|
||||||
|
elif curses.ascii.isdigit(char):
|
||||||
|
self.handle_digit_input(char)
|
||||||
|
elif char == curses.KEY_MOUSE:
|
||||||
|
self.handle_mouse(*curses.getmouse())
|
||||||
|
elif char == curses.KEY_RESIZE:
|
||||||
|
self.handle_resize()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_window_refresh_size(self):
|
||||||
|
return self.h - 3, self.w - 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_window_pos(self):
|
||||||
|
return self.h - 2, 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def command_window_pos(self):
|
||||||
|
return self.h - 1, 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_dim(self):
|
||||||
|
return 1, self.w
|
||||||
|
|
||||||
|
def refresh_windows(self):
|
||||||
|
self.refresh_content()
|
||||||
|
self.refresh_status()
|
||||||
|
self.clear_command()
|
||||||
|
|
||||||
|
def refresh_content(self):
|
||||||
|
"""Refresh content pad's view using the current line/column."""
|
||||||
|
refresh_size = self.content_window_refresh_size
|
||||||
|
if refresh_size[0] <= 0 or refresh_size[1] <= 0:
|
||||||
|
return
|
||||||
|
self.content_pad.refresh(
|
||||||
|
self.current_line, self.current_column, 0, 0, *refresh_size
|
||||||
|
)
|
||||||
|
|
||||||
|
def refresh_status(self):
|
||||||
|
"""Refresh status line contents."""
|
||||||
|
text, pair = self.status_data
|
||||||
|
text = text[:self.w - 1]
|
||||||
|
self.status_window.addstr(0, 0, text, curses.color_pair(pair))
|
||||||
|
self.status_window.clrtoeol()
|
||||||
|
self.status_window.refresh()
|
||||||
|
|
||||||
|
def scroll_content(self, num_lines: int, scroll_up: bool =False):
|
||||||
|
"""Make the content pad scroll up and down by *num_lines*."""
|
||||||
|
if scroll_up:
|
||||||
|
min_line = 0
|
||||||
|
if self.current_line > min_line:
|
||||||
|
self.current_line = max(self.current_line - num_lines, min_line)
|
||||||
|
self.refresh_content()
|
||||||
|
else:
|
||||||
|
max_line = self.content_pad_dim[0] - self.h + 2
|
||||||
|
if self.current_line < max_line:
|
||||||
|
self.current_line = min(self.current_line + num_lines, max_line)
|
||||||
|
self.refresh_content()
|
||||||
|
|
||||||
|
def set_status(self, text):
|
||||||
|
"""Set a regular message in the status bar."""
|
||||||
|
self.status_data = text, ColorPair.NORMAL
|
||||||
|
self.refresh_status()
|
||||||
|
|
||||||
|
def set_status_error(self, text):
|
||||||
|
"""Set an error message in the status bar."""
|
||||||
|
self.status_data = f"Error: {text}", ColorPair.ERROR
|
||||||
|
self.refresh_status()
|
||||||
|
|
||||||
|
def open_url(self, url):
|
||||||
|
"""Try to open an URL.
|
||||||
|
|
||||||
|
If the URL is not strictly absolute, it will be opened relatively to the
|
||||||
|
current URL, unless there is no current URL yet.
|
||||||
|
"""
|
||||||
|
if self.current_url:
|
||||||
|
parts = parse_url(url)
|
||||||
|
else:
|
||||||
|
parts = parse_url(url, absolute=True)
|
||||||
|
if parts.scheme == "gemini":
|
||||||
|
if not parts.netloc:
|
||||||
|
url = join_url(self.current_url, url)
|
||||||
|
self.open_gemini_url(url)
|
||||||
|
else:
|
||||||
|
self.set_status_error(f"protocol {parts.scheme} not supported.")
|
||||||
|
|
||||||
|
def open_gemini_url(self, url):
|
||||||
|
"""Open a Gemini URL and set the formatted response as content."""
|
||||||
|
self.set_status(f"Loading {url}")
|
||||||
|
req = Request(url, self.stash)
|
||||||
|
connected = req.connect()
|
||||||
|
if not connected:
|
||||||
|
if req.state == Request.STATE_ERROR_CERT:
|
||||||
|
self.set_status_error("certificate was missing or corrupt.")
|
||||||
|
elif req.state == Request.STATE_UNTRUSTED_CERT:
|
||||||
|
self.set_status_error("certificate has been changed.")
|
||||||
|
# TODO propose the user ways to handle this.
|
||||||
|
else:
|
||||||
|
self.set_status_error("connection failed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if req.state == Request.STATE_INVALID_CERT:
|
||||||
|
# TODO propose abort / temp trust
|
||||||
|
pass
|
||||||
|
elif req.state == Request.STATE_UNKNOWN_CERT:
|
||||||
|
# TODO propose abort / temp trust / perm trust
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
pass # TODO
|
||||||
|
|
||||||
|
response = Response.parse(req.proceed())
|
||||||
|
if not response:
|
||||||
|
self.set_status_error("server response parsing failed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if response.code != 20:
|
||||||
|
self.set_status_error(f"unknown response code {response.code}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.set_status(url)
|
||||||
|
self.current_url = url
|
||||||
|
self.show_gemtext(response.content)
|
||||||
|
|
||||||
|
def show_gemtext(self, gemtext: bytes):
|
||||||
|
"""Render Gemtext data in the content pad."""
|
||||||
|
elements = parse_gemtext(gemtext)
|
||||||
|
self.metalines = format_elements(elements, 80)
|
||||||
|
self.links = {
|
||||||
|
meta["link_id"]: meta["url"]
|
||||||
|
for meta, _ in self.metalines
|
||||||
|
if "link_id" in meta and "url" in meta
|
||||||
|
}
|
||||||
|
|
||||||
|
self.content_pad.clear()
|
||||||
|
h, w = render_lines(self.metalines, self.content_pad, Screen.MAX_COLS)
|
||||||
|
self.content_pad_dim = (h, w)
|
||||||
|
self.current_line = 0
|
||||||
|
self.current_column = 0
|
||||||
|
self.refresh_content()
|
||||||
|
|
||||||
|
def focus_command(self, command_char, validator=None, prefix=""):
|
||||||
|
"""Give user focus to the command bar.
|
||||||
|
|
||||||
|
Show the command char and give focus to the command textbox. The
|
||||||
|
validator function is passed to the textbox.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
- command_char: char to display before the command line.
|
||||||
|
- validator: function to use to validate the input chars.
|
||||||
|
- prefix: string to insert before the cursor in the command line.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User input as string. The string will be empty if the validator raised
|
||||||
|
an EscapeInterrupt.
|
||||||
|
"""
|
||||||
|
assert self.command_window is not None
|
||||||
|
self.command_window.clear()
|
||||||
|
self.command_window.refresh()
|
||||||
|
self.command_textbox = curses.textpad.Textbox(self.command_window)
|
||||||
|
self.command_window.addstr(command_char + prefix)
|
||||||
|
curses.curs_set(1)
|
||||||
|
try:
|
||||||
|
command = self.command_textbox.edit(validator)[1:]
|
||||||
|
except EscapeCommandInterrupt:
|
||||||
|
command = ""
|
||||||
|
except TerminateCommandInterrupt as exc:
|
||||||
|
command = exc.command
|
||||||
|
curses.curs_set(0)
|
||||||
|
self.clear_command()
|
||||||
|
return command
|
||||||
|
|
||||||
|
def gather_current_command(self):
|
||||||
|
"""Return the string currently written by the user in command line."""
|
||||||
|
return self.command_textbox.gather()[1:].rstrip()
|
||||||
|
|
||||||
|
def clear_command(self):
|
||||||
|
"""Clear the command line """
|
||||||
|
self.command_window.clear()
|
||||||
|
self.command_window.refresh()
|
||||||
|
self.screen.delch(self.h - 1, 0)
|
||||||
|
self.screen.refresh()
|
||||||
|
|
||||||
|
def input_common_command(self):
|
||||||
|
"""Focus command line to type a regular command. Currently useless."""
|
||||||
|
return self.focus_command(":", self.validate_common_char)
|
||||||
|
|
||||||
|
def validate_common_char(self, ch: int):
|
||||||
|
"""Generic input validator, handles a few more cases than default.
|
||||||
|
|
||||||
|
This validator can be used as a default validator as it handles, on top
|
||||||
|
of the Textbox defaults:
|
||||||
|
- Erasing the first command char, i.e. clearing the line, cancels the
|
||||||
|
command input.
|
||||||
|
- Pressing ESC also cancels the input.
|
||||||
|
|
||||||
|
This validator can be safely called at the beginning of other validators
|
||||||
|
to handle the keys above.
|
||||||
|
"""
|
||||||
|
if ch == curses.KEY_BACKSPACE: # Cancel input if all line is cleaned.
|
||||||
|
text = self.gather_current_command()
|
||||||
|
if len(text) == 0:
|
||||||
|
raise EscapeCommandInterrupt()
|
||||||
|
elif ch == curses.ascii.ESC: # Could be ESC or ALT
|
||||||
|
self.screen.nodelay(True)
|
||||||
|
ch = self.screen.getch()
|
||||||
|
if ch == -1:
|
||||||
|
raise EscapeCommandInterrupt()
|
||||||
|
self.screen.nodelay(False)
|
||||||
|
return ch
|
||||||
|
|
||||||
|
def handle_digit_input(self, init_char: int):
|
||||||
|
"""Handle a initial digit input by the user.
|
||||||
|
|
||||||
|
When a digit key is pressed, the user intents to visit a link (or
|
||||||
|
dropped something on the numpad). To reduce the number of key types
|
||||||
|
needed, Bebop uses the following algorithm:
|
||||||
|
- If the highest link ID on the page is less than 10, pressing the key
|
||||||
|
takes you to the link.
|
||||||
|
- If it's higher than 10, the user either inputs as many digits required
|
||||||
|
to disambiguate the link ID, or press enter to validate her input.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
- I have 3 links. Pressing "2" takes me to link 2.
|
||||||
|
- I have 15 links. Pressing "3" and Enter takes me to link 2.
|
||||||
|
- I have 15 links. Pressing "1" and "2" takes me to link 12 (no
|
||||||
|
ambiguity, so Enter is not required).
|
||||||
|
- I have 456 links. Pressing "1", "2" and Enter takes me to link 12.
|
||||||
|
- I have 456 links. Pressing "1", "2" and "6" takes me to link 126 (no
|
||||||
|
ambiguity as well).
|
||||||
|
"""
|
||||||
|
digit = init_char & 0xf
|
||||||
|
num_links = len(self.links)
|
||||||
|
if num_links < 10:
|
||||||
|
self.open_link(digit)
|
||||||
|
return
|
||||||
|
required_digits = 0
|
||||||
|
while num_links:
|
||||||
|
required_digits += 1
|
||||||
|
num_links //= 10
|
||||||
|
link_input = self.focus_command(
|
||||||
|
"~",
|
||||||
|
validator=lambda ch: self._validate_link_digit(ch, required_digits),
|
||||||
|
prefix=chr(init_char),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
link_id = int(link_input)
|
||||||
|
except ValueError:
|
||||||
|
self.set_status_error("invalid link ID")
|
||||||
|
return
|
||||||
|
self.open_link(link_id)
|
||||||
|
|
||||||
|
def _validate_link_digit(self, ch: int, required_digits: int):
|
||||||
|
"""Handle input chars to be used as link ID."""
|
||||||
|
# Handle common chars.
|
||||||
|
ch = self.validate_common_char(ch)
|
||||||
|
# Only accept digits. If we reach the amount of required digits, open
|
||||||
|
# link now and leave command line. Else just process it.
|
||||||
|
if curses.ascii.isdigit(ch):
|
||||||
|
digits = self.gather_current_command()
|
||||||
|
if len(digits) + 1 == required_digits:
|
||||||
|
raise TerminateCommandInterrupt(digits + chr(ch))
|
||||||
|
return ch
|
||||||
|
# If not a digit but a printable character, ignore it.
|
||||||
|
if curses.ascii.isprint(ch):
|
||||||
|
return 0
|
||||||
|
# Everything else could be a control character and should be processed.
|
||||||
|
return ch
|
||||||
|
|
||||||
|
def disambiguate_link_id(self, digits: str, max_digits: int):
|
||||||
|
if len(digits) == max_digits:
|
||||||
|
return digits
|
||||||
|
|
||||||
|
|
||||||
|
def open_link(self, link_id: int):
|
||||||
|
"""Open the link with this link ID."""
|
||||||
|
if not link_id in self.links:
|
||||||
|
self.set_status_error(f"unknown link ID {link_id}.")
|
||||||
|
return
|
||||||
|
self.open_url(self.links[link_id])
|
||||||
|
|
||||||
|
def handle_mouse(self, mouse_id: int, x: int, y: int, z: int, bstate: int):
|
||||||
|
"""Handle mouse events.
|
||||||
|
|
||||||
|
Right now, only vertical scrolling is handled.
|
||||||
|
"""
|
||||||
|
if bstate & ButtonState.SCROLL_UP:
|
||||||
|
self.scroll_content(3, scroll_up=True)
|
||||||
|
elif bstate & ButtonState.SCROLL_DOWN:
|
||||||
|
self.scroll_content(3)
|
||||||
|
|
||||||
|
def handle_resize(self):
|
||||||
|
"""Try to not make everything collapse on resizes."""
|
||||||
|
# Refresh the whole screen before changing windows to avoid random
|
||||||
|
# blank screens.
|
||||||
|
self.screen.refresh()
|
||||||
|
old_dim = self.dim
|
||||||
|
self.dim = self.screen.getmaxyx()
|
||||||
|
# Avoid work if the resizing does not impact us.
|
||||||
|
if self.dim == old_dim:
|
||||||
|
return
|
||||||
|
# Resize windows to fit the new dimensions. Content pad will be updated
|
||||||
|
# on its own at the end of the function.
|
||||||
|
self.status_window.resize(*self.line_dim)
|
||||||
|
self.command_window.resize(*self.line_dim)
|
||||||
|
# Move the windows to their new position if that's still possible.
|
||||||
|
if self.status_window_pos[0] >= 0:
|
||||||
|
self.status_window.mvwin(*self.status_window_pos)
|
||||||
|
if self.command_window_pos[0] >= 0:
|
||||||
|
self.command_window.mvwin(*self.command_window_pos)
|
||||||
|
# If the content pad does not fit its whole place, we have to clean the
|
||||||
|
# gap between it and the status line. Refresh all screen.
|
||||||
|
if self.content_pad_dim[0] < self.h - 2:
|
||||||
|
self.screen.clear()
|
||||||
|
self.screen.refresh()
|
||||||
|
self.refresh_windows()
|
||||||
|
|
||||||
|
|
||||||
|
class EscapeCommandInterrupt(Exception):
|
||||||
|
"""Signal that ESC has been pressed during command line."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TerminateCommandInterrupt(Exception):
|
||||||
|
"""Signal that validation ended command line input early. Use `command`."""
|
||||||
|
|
||||||
|
def __init__(self, command: str, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.command = command
|
0
bebop/tests/__init__.py
Normal file
0
bebop/tests/__init__.py
Normal file
30
bebop/tests/test_navigation.py
Normal file
30
bebop/tests/test_navigation.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from ..navigation import join_url, parse_url
|
||||||
|
|
||||||
|
|
||||||
|
class TestNavigation(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_parse_url(self):
|
||||||
|
res = parse_url("gemini://dece.space/parse-me.gmi")
|
||||||
|
self.assertEqual(res.scheme, "gemini")
|
||||||
|
self.assertEqual(res.netloc, "dece.space")
|
||||||
|
self.assertEqual(res.path, "/parse-me.gmi")
|
||||||
|
|
||||||
|
res_netloc = parse_url("//dece.space/parse-me.gmi")
|
||||||
|
self.assertEqual(res, res_netloc)
|
||||||
|
|
||||||
|
res = parse_url("dece.space/parse-me.gmi", absolute=True)
|
||||||
|
self.assertEqual(res.scheme, "gemini")
|
||||||
|
self.assertEqual(res.netloc, "dece.space")
|
||||||
|
self.assertEqual(res.path, "/parse-me.gmi")
|
||||||
|
|
||||||
|
def test_join_url(self):
|
||||||
|
url = join_url("gemini://dece.space", "some-file.gmi")
|
||||||
|
self.assertEqual(url, "gemini://dece.space/some-file.gmi")
|
||||||
|
url = join_url("gemini://dece.space", "some-file.gmi")
|
||||||
|
self.assertEqual(url, "gemini://dece.space/some-file.gmi")
|
||||||
|
url = join_url("gemini://dece.space/dir1/file.gmi", "other-file.gmi")
|
||||||
|
self.assertEqual(url, "gemini://dece.space/dir1/other-file.gmi")
|
||||||
|
url = join_url("gemini://dece.space/dir1/file.gmi", "../top-level.gmi")
|
||||||
|
self.assertEqual(url, "gemini://dece.space/top-level.gmi")
|
10
bebop/tests/test_protocol.py
Normal file
10
bebop/tests/test_protocol.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from ..protocol import parse_gemini_url
|
||||||
|
|
||||||
|
|
||||||
|
class TestGemini(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_parse_url(self):
|
||||||
|
r1 = parse_gemini_url("gemini://dece.space")
|
||||||
|
self.assertDictEqual(r1, {"host": "dece.space", "path": ""})
|
41
bebop/tests/test_rendering.py
Normal file
41
bebop/tests/test_rendering.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from ..rendering import _explode_words, _find_next_sep, wrap_words
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderer(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_wrap_words(self):
|
||||||
|
t = "wrap me wrap me youcantwrapthisonewithoutforce bla bla bla bla"
|
||||||
|
lines = wrap_words(t, 10)
|
||||||
|
expected = [
|
||||||
|
"wrap me ",
|
||||||
|
"wrap me ",
|
||||||
|
"youcantwr-",
|
||||||
|
"apthisone-",
|
||||||
|
"withoutfo-",
|
||||||
|
"rce bla ",
|
||||||
|
"bla bla ",
|
||||||
|
"bla",
|
||||||
|
]
|
||||||
|
self.assertListEqual(lines, expected)
|
||||||
|
|
||||||
|
def test_explode_words(self):
|
||||||
|
t = "unsplittableword word-dash tabatmyleft dot.sep"
|
||||||
|
words = _explode_words(t)
|
||||||
|
expected = [
|
||||||
|
"unsplittableword", " ", "word-", "dash", " ", "tabatmyleft",
|
||||||
|
" ", "dot.sep"
|
||||||
|
]
|
||||||
|
self.assertListEqual(words, expected)
|
||||||
|
|
||||||
|
def test_find_next_sep(self):
|
||||||
|
t = "unsplittableword word-dash"
|
||||||
|
sep, index = _find_next_sep(t)
|
||||||
|
self.assertEqual((sep, index), (" ", 16))
|
||||||
|
t = t[17:]
|
||||||
|
sep, index = _find_next_sep(t)
|
||||||
|
self.assertEqual((sep, index), ("-", 4))
|
||||||
|
t = t[5:]
|
||||||
|
sep, index = _find_next_sep(t)
|
||||||
|
self.assertEqual((sep, index), ("", 0))
|
95
bebop/tofu.py
Normal file
95
bebop/tofu.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import asn1crypto.x509
|
||||||
|
|
||||||
|
STASH_LINE_RE = re.compile(r"(\S+) (\S+) (\S+) (\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def load_cert_stash(stash_path):
|
||||||
|
"""Load the certificate stash from the file, or None on error.
|
||||||
|
|
||||||
|
The stash is a dict with host names as keys and tuples as values. Tuples
|
||||||
|
have four elements:
|
||||||
|
- the fingerprint algorithm (only SHA-512 is supported),
|
||||||
|
- the fingerprint as an hexstring,
|
||||||
|
- the timestamp of the expiration date,
|
||||||
|
- a boolean that is True when the stash is loaded from a file, i.e. always
|
||||||
|
true for entries loaded in this function, but should be false when it
|
||||||
|
concerns a certificate temporary trusted for the session only; this flag
|
||||||
|
is used to decide whether to save the certificate in the stash at exit.
|
||||||
|
"""
|
||||||
|
stash = {}
|
||||||
|
try:
|
||||||
|
with open(stash_path, "rt") as stash_file:
|
||||||
|
for line in stash_file:
|
||||||
|
match = STASH_LINE_RE.match(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
name, algo, fingerprint, timestamp = match.groups()
|
||||||
|
stash[name] = (algo, fingerprint, timestamp, True)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return stash
|
||||||
|
|
||||||
|
|
||||||
|
class CertStatus(Enum):
|
||||||
|
"""Value returned by validate_cert."""
|
||||||
|
# Cert is valid: proceed.
|
||||||
|
VALID = 0 # Known and valid.
|
||||||
|
VALID_NEW = 7 # New and valid.
|
||||||
|
# Cert is unusable or wrong: abort.
|
||||||
|
ERROR = 1 # General error.
|
||||||
|
WRONG_FINGERPRINT = 2 # Fingerprint in the stash is different.
|
||||||
|
# Cert has some issues: ask to proceed.
|
||||||
|
NOT_VALID_YET = 3 # not-before date invalid.
|
||||||
|
EXPIRED = 4 # not-after date invalid.
|
||||||
|
BAD_DOMAIN = 5 # Host name is not in cert's valid domains.
|
||||||
|
|
||||||
|
|
||||||
|
CERT_STATUS_INVALID = (
|
||||||
|
CertStatus.NOT_VALID_YET,
|
||||||
|
CertStatus.EXPIRED,
|
||||||
|
CertStatus.BAD_DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_cert(der, hostname, cert_stash):
|
||||||
|
"""Return a tuple (CertStatus, Certificate) for this certificate."""
|
||||||
|
if der is None:
|
||||||
|
return CertStatus.ERROR, None
|
||||||
|
try:
|
||||||
|
cert = asn1crypto.x509.Certificate.load(der)
|
||||||
|
except ValueError:
|
||||||
|
return CertStatus.ERROR, None
|
||||||
|
|
||||||
|
# Check for sane parameters.
|
||||||
|
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||||
|
if now < cert.not_valid_before:
|
||||||
|
return CertStatus.NOT_VALID_YET, cert
|
||||||
|
if now > cert.not_valid_after:
|
||||||
|
return CertStatus.EXPIRED, cert
|
||||||
|
if hostname not in cert.valid_domains:
|
||||||
|
return CertStatus.BAD_DOMAIN, cert
|
||||||
|
|
||||||
|
# Check the entire certificate fingerprint.
|
||||||
|
cert_hash = hashlib.sha512(der).hexdigest()
|
||||||
|
if hostname in cert_stash:
|
||||||
|
_, fingerprint, timestamp, _ = cert_stash[hostname]
|
||||||
|
if timestamp >= now.timestamp():
|
||||||
|
if cert_hash != fingerprint:
|
||||||
|
return CertStatus.WRONG_FINGERPRINT, cert
|
||||||
|
else:
|
||||||
|
# Disregard expired fingerprints.
|
||||||
|
pass
|
||||||
|
return CertStatus.VALID, cert
|
||||||
|
|
||||||
|
# The certificate is unknown and valid.
|
||||||
|
return CertStatus.VALID_NEW, cert
|
||||||
|
|
||||||
|
|
||||||
|
def trust(cert_stash, hostname, algo, fingerprint, timestamp,
|
||||||
|
trust_always=False):
|
||||||
|
cert_stash[hostname] = (algo, fingerprint, timestamp, trust_always)
|
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
asn1crypto
|
Reference in a new issue