Compare commits

..

3 commits

Author SHA1 Message Date
dece f687a80dba WIP 2021-11-27 11:36:11 +01:00
dece 1604456bc9 browser: add quit info in the C-c message 2021-09-20 08:17:47 +02:00
dece 2ccb056756 browser: add i keybind to show page info 2021-09-20 08:16:32 +02:00
9 changed files with 159 additions and 82 deletions

View file

@ -29,14 +29,13 @@ handle big files (e.g. gemini://tilde.team/~tomasino/irc/log.txt)
allow encoding overrides (useful for gopher i guess) allow encoding overrides (useful for gopher i guess)
config for web browser, default to webbrowser module config for web browser, default to webbrowser module
use pubkeys instead of the whole DER hash for TOFU use pubkeys instead of the whole DER hash for TOFU
reload after forgetting old cert
specify external commands interface (exec, run, pipes) specify external commands interface (exec, run, pipes)
table of contents table of contents
better default editor than vim better default editor than vim
search engine search engine
auto-open some media types auto-open some media types
don't store explicit port 1965 in URL prefixes (e.g. for identities) use about scheme instead of bebop
fix shifted line detection when text goes belong the horizontal limit
auto-rewrite missing "/" in empty URL paths

View file

@ -54,8 +54,9 @@ have it installed, check out this Gemini link `gemini://dece.space/dev/faq/using
[py-faq-http]: https://portal.mozz.us/gemini/dece.space/dev/faq/using-python-programs.gmi [py-faq-http]: https://portal.mozz.us/gemini/dece.space/dev/faq/using-python-programs.gmi
The recommended installation method is using Pipx, but using Pip is fine, either The easier installation method is using Pip, either user or system-wide
user or system-wide installation. installation. I recommend user installation, but a system-wide installation
should not cause issues as there are no dependencies.
```bash ```bash
# User installation: # User installation:
@ -123,16 +124,6 @@ Here is a list of plugins I did, available on PyPI:
[plugin-finger]: plugins/finger/README.md [plugin-finger]: plugins/finger/README.md
[plugin-gopher]: plugins/gopher/README.md [plugin-gopher]: plugins/gopher/README.md
Plugins have to be installed in the same Python environment as Bebop. If you
installed Bebop using Pipx, this is done using the `inject` command.
```bash
# Installating the Gopher plugin in the user environment:
pip3 install --user bebop-browser-gopher
# Installating the Gopher plugin for a Pipx installation:
pipx inject bebop-browser bebop-browser-gopher
```
Usage Usage

View file

@ -19,7 +19,7 @@ from bebop.bookmarks import (
) )
from bebop.colors import A_ITALIC, ColorPair, init_colors from bebop.colors import A_ITALIC, ColorPair, init_colors
from bebop.command_line import CommandLine from bebop.command_line import CommandLine
from bebop.external import open_external_program from bebop.external import open_external_program, substitute_external_command
from bebop.fs import get_capsule_prefs_path, get_identities_list_path from bebop.fs import get_capsule_prefs_path, get_identities_list_path
from bebop.help import get_help from bebop.help import get_help
from bebop.history import History from bebop.history import History
@ -69,7 +69,6 @@ class Browser:
SEARCH_NEXT = 0 SEARCH_NEXT = 0
SEARCH_PREVIOUS = 1 SEARCH_PREVIOUS = 1
MAX_REDIRECTIONS = 5
def __init__(self, config, cert_stash): def __init__(self, config, cert_stash):
self.config = config self.config = config
@ -269,6 +268,8 @@ class Browser:
self.move_to_search_result(Browser.SEARCH_PREVIOUS) self.move_to_search_result(Browser.SEARCH_PREVIOUS)
elif char == ord("i"): elif char == ord("i"):
self.show_page_info() self.show_page_info()
elif char == ord("!"):
self.quick_command("exec")
elif curses.ascii.isdigit(char): elif curses.ascii.isdigit(char):
self.handle_digit_input(char) self.handle_digit_input(char)
elif char == curses.KEY_MOUSE: elif char == curses.KEY_MOUSE:
@ -385,6 +386,8 @@ class Browser:
else: else:
if command in ("o", "open"): if command in ("o", "open"):
self.open_url(words[1]) self.open_url(words[1])
elif command == "exec":
self.exec_external_command(" ".join(words[1:]))
elif command == "forget-certificate": elif command == "forget-certificate":
from bebop.browser.gemini import forget_certificate from bebop.browser.gemini import forget_certificate
forget_certificate(self, words[1]) forget_certificate(self, words[1])
@ -423,7 +426,7 @@ class Browser:
- history: whether the URL should be pushed to history on success. - history: whether the URL should be pushed to history on success.
- use_cache: whether we should look for an already cached document. - use_cache: whether we should look for an already cached document.
""" """
if redirects > self.MAX_REDIRECTIONS: if redirects > 5:
self.set_status_error(f"Too many redirections ({url}).") self.set_status_error(f"Too many redirections ({url}).")
return return
@ -789,7 +792,7 @@ class Browser:
encoding = page.encoding or "unk. encoding" encoding = page.encoding or "unk. encoding"
size = f"{len(page.source)} chars" size = f"{len(page.source)} chars"
lines = f"{len(page.metalines)} lines" lines = f"{len(page.metalines)} lines"
info = f"{mime} {encoding} {size} {lines}" info = f"{mime}; {encoding}; {size}; {lines}"
self.set_status(info) self.set_status(info)
def set_render_mode(self, mode): def set_render_mode(self, mode):
@ -890,3 +893,16 @@ class Browser:
continue continue
logging.info(f"Loaded plugin {plugin_name}.") logging.info(f"Loaded plugin {plugin_name}.")
def exec_external_command(self, command):
if not self.current_page:
return
command = substitute_external_command(
command, self.current_url, self.current_page)
self.set_status(f"Launching `{command}`…")
success = open_external_program(command)
if success:
self.reset_status()
else:
self.set_status_error("Command failed.")
self.refresh_windows()

View file

@ -24,8 +24,8 @@ MAX_URL_LEN = 1024
def open_gemini_url( def open_gemini_url(
browser: Browser, browser: Browser,
url: str, url: str,
redirects: int = 0, redirects: int =0,
use_cache: bool = False, use_cache: bool =False,
cert_and_key=None cert_and_key=None
) -> Optional[str]: ) -> Optional[str]:
"""Open a Gemini URL and set the formatted response as content. """Open a Gemini URL and set the formatted response as content.
@ -41,9 +41,9 @@ def open_gemini_url(
present the user the problems found and let her decide whether to trust present the user the problems found and let her decide whether to trust
temporarily the certificate or not BUT we currently do not parse the temporarily the certificate or not BUT we currently do not parse the
certificate's fields, not even the pubkey, so this state is never used. certificate's fields, not even the pubkey, so this state is never used.
- STATE_UNKNOWN_CERT: the certificate is valid but has not been seen - STATE_UNKNOWN_CERT: the certificate is valid but has not been seen before;
before; as we're doing TOFU here, we could automatically trust it or let as we're doing TOFU here, we could automatically trust it or let the user
the user choose. For simplicity, we always trust it permanently. choose. For simplicity, we always trust it permanently.
Arguments: Arguments:
- browser: Browser object making the request. - browser: Browser object making the request.
@ -111,21 +111,16 @@ def open_gemini_url(
trust_always=True trust_always=True
) )
try:
data = req.proceed() data = req.proceed()
except OSError:
browser.set_status_error(f"Connection error ({url}).")
return None
if not data: if not data:
browser.set_status_error(f"Response empty or timed out ({url}).") browser.set_status_error(f"Server did not respond in time ({url}).")
return None return None
response = Response.parse(data) response = Response.parse(data)
if not response: if not response:
browser.set_status_error(f"Response parsing failed ({url}).") browser.set_status_error(f"Server response parsing failed ({url}).")
return None return None
return _handle_response(browser, response, url, redirects, return _handle_response(browser, response, url, redirects)
used_cert=cert_and_key is not None)
def _handle_untrusted_cert(browser: Browser, request: Request): def _handle_untrusted_cert(browser: Browser, request: Request):
@ -152,8 +147,7 @@ def _handle_response(
browser: Browser, browser: Browser,
response: Response, response: Response,
url: str, url: str,
redirects: int, redirects: int
used_cert: bool = False,
) -> Optional[str]: ) -> Optional[str]:
"""Handle a response from a Gemini server. """Handle a response from a Gemini server.
@ -172,15 +166,11 @@ def _handle_response(
redirects=redirects + 1 redirects=redirects + 1
) )
elif response.generic_code in (40, 50): elif response.generic_code in (40, 50):
error = f"Server error: {response.meta or Response.code.name}" error = f"Server error: {response.meta or response.code.name}"
browser.set_status_error(error) browser.set_status_error(error)
elif response.generic_code == 10: elif response.generic_code == 10:
return _handle_input_request(browser, url, response.meta) return _handle_input_request(browser, url, response.meta)
elif response.code == 60: elif response.code == 60:
if used_cert:
error = "Server ignored our certificate."
browser.set_status_error(error)
else:
return _handle_cert_required(browser, response, url, redirects) return _handle_cert_required(browser, response, url, redirects)
elif response.code in (61, 62): elif response.code in (61, 62):
details = response.meta or Response.code.name details = response.meta or Response.code.name
@ -192,11 +182,7 @@ def _handle_response(
return None return None
def _handle_successful_response( def _handle_successful_response(browser: Browser, response: Response, url: str):
browser: Browser,
response: Response,
url: str
):
"""Handle a successful response content from a Gemini server. """Handle a successful response content from a Gemini server.
According to the MIME type received or inferred, the response is either According to the MIME type received or inferred, the response is either
@ -229,8 +215,7 @@ def _handle_successful_response(
error = f"Unknown encoding {encoding}." error = f"Unknown encoding {encoding}."
else: else:
render_opts = get_render_options(browser.config) render_opts = get_render_options(browser.config)
pref_mode = get_url_render_mode_pref( pref_mode = get_url_render_mode_pref(browser.capsule_prefs, url)
browser.capsule_prefs, url)
if pref_mode: if pref_mode:
render_opts.mode = pref_mode render_opts.mode = pref_mode
page = Page.from_gemtext(text, render_opts) page = Page.from_gemtext(text, render_opts)
@ -270,7 +255,7 @@ def _handle_successful_response(
def _handle_input_request( def _handle_input_request(
browser: Browser, browser: Browser,
from_url: str, from_url: str,
message: str = None message: str =None
) -> Optional[str]: ) -> Optional[str]:
"""Focus command-line to pass input to the server. """Focus command-line to pass input to the server.
@ -326,12 +311,11 @@ def _handle_cert_required(
def select_identity(identities: list): def select_identity(identities: list):
"""Let user select the appropriate identity among candidates.""" """Let user select the appropriate identity among candidates."""
# TODO support multiple identities; for now we just use the first # TODO support multiple identities; for now we just use the first available.
# available.
return identities[0] if identities else None return identities[0] if identities else None
def create_identity(browser: Browser, url: str, reason: Optional[str] = None): def create_identity(browser: Browser, url: str, reason: Optional[str] =None):
"""Walk the user through identity creation. """Walk the user through identity creation.
Returns: Returns:
@ -368,7 +352,7 @@ def create_identity(browser: Browser, url: str, reason: Optional[str] = None):
def forget_certificate(browser: Browser, hostname: str): def forget_certificate(browser: Browser, hostname: str):
"""Remove the fingerprint for this hostname from the cert stash.""" """Remove the fingerprint associated to this hostname for the cert stash."""
key = browser.prompt(f"Remove fingerprint for {hostname}?") key = browser.prompt(f"Remove fingerprint for {hostname}?")
if key != "y": if key != "y":
browser.reset_status() browser.reset_status()
@ -376,5 +360,4 @@ def forget_certificate(browser: Browser, hostname: str):
if untrust_fingerprint(browser.stash, hostname): if untrust_fingerprint(browser.stash, hostname):
browser.set_status(f"Known certificate for {hostname} removed.") browser.set_status(f"Known certificate for {hostname} removed.")
else: else:
browser.set_status_error( browser.set_status_error(f"Known certificate for {hostname} not found.")
f"Known certificate for {hostname} not found.")

View file

@ -2,7 +2,24 @@
import curses import curses
import logging import logging
import re
import subprocess import subprocess
import tempfile
from bebop.page import Page
def _pre_exec():
curses.nocbreak()
curses.echo()
curses.curs_set(1)
def _post_exec():
curses.mousemask(curses.ALL_MOUSE_EVENTS)
curses.curs_set(0)
curses.noecho()
curses.cbreak()
def open_external_program(command): def open_external_program(command):
@ -14,17 +31,48 @@ def open_external_program(command):
Returns: Returns:
True if no exception occured. True if no exception occured.
""" """
curses.nocbreak() _pre_exec()
curses.echo()
curses.curs_set(1)
result = True result = True
try: try:
subprocess.run(command) subprocess.run(command)
except OSError as exc: except OSError as exc:
logging.error(f"Failed to run '{command}': {exc}") logging.error(f"Failed to run '{command}': {exc}")
result = False result = False
curses.mousemask(curses.ALL_MOUSE_EVENTS) _post_exec()
curses.curs_set(0)
curses.noecho()
curses.cbreak()
return result return result
SUB_URL_RE = re.compile(r"(?<!\$)\$u")
SUB_SRC_RE = re.compile(r"(?<!\$)\$s")
SUB_LINK_RE = re.compile(r"(?<!\$)\$(\d+)")
SUB_LITERAL_RE = re.compile(r"\$\$")
def substitute_external_command(command: str, url: str, page: Page):
"""Substitute "$" parts of the command with corresponding values.
Valid substitutions are:
- $u = current url
- $n (with n any positive number) = link url
- $s = current page source temp file
- $$ = $
Returns:
The command with all the template parts replaced with the corresponding
strings.
Raises:
ValueError if a substitution is wrong, e.g. a link ID which does not exist.
"""
# URL substitution.
command = SUB_URL_RE.sub(url, command)
# Source file substitution.
if SUB_SRC_RE.search(command):
with tempfile.NamedTemporaryFile("wt", delete=False) as source_file:
source_file.write(page.source)
command = SUB_SRC_RE.sub(source_file.name, command)
# Link ID substitution.
command = SUB_LINK_RE.sub(lambda m: page.links[int(m.group(1))], command)
# Literal dollar sign.
command = SUB_LITERAL_RE.sub("$", command)
return command

View file

@ -102,6 +102,7 @@ def parse_gemtext(text: str, dumb=False) -> ParsedGemtext:
match = Blockquote.RE.match(line) match = Blockquote.RE.match(line)
if match: if match:
text = match.groups()[0] text = match.groups()[0]
if text or dumb:
elements.append(Blockquote(text)) elements.append(Blockquote(text))
continue continue

View file

@ -104,7 +104,7 @@ def get_help(config, plugins):
config_list = "\n".join( config_list = "\n".join(
( (
f"* {key} = {config[key]} (default {repr(DEFAULT_CONFIG[key])})" f"* {key} = {config[key]} (default {repr(DEFAULT_CONFIG[key])})"
if key in DEFAULT_CONFIG and config[key] != DEFAULT_CONFIG[key] if config[key] != DEFAULT_CONFIG[key]
else f"* {key} = {config[key]}" else f"* {key} = {config[key]}"
) )
for key in sorted(config) for key in sorted(config)

View file

@ -26,8 +26,8 @@ class Request:
sending the request header and receiving the response: sending the request header and receiving the response:
1. Instantiate a Request. 1. Instantiate a Request.
2. `connect` opens the connection and aborts it or leaves the caller free 2. `connect` opens the connection and aborts it or leaves the caller free to
to check stuff. check stuff.
3. `proceed` or `abort` can be called. 3. `proceed` or `abort` can be called.
Attributes: Attributes:
@ -35,8 +35,8 @@ class Request:
- cert_stash: certificate stash to use an possibly update. - cert_stash: certificate stash to use an possibly update.
- state: request state. - state: request state.
- hostname: hostname derived from url, stored when `connect` is called. - hostname: hostname derived from url, stored when `connect` is called.
- payload: bytes object of the payload request; build during `connect`, - payload: bytes object of the payload request; build during `connect`, used
used during `proceed`. during `proceed`.
- ssock: TLS-wrapped socket. - ssock: TLS-wrapped socket.
- cert_validation: validation results dict, set after certificate has been - cert_validation: validation results dict, set after certificate has been
reviewed. reviewed.
@ -79,12 +79,12 @@ class Request:
certificate status is not CertStatus.VALID (Request.STATE_OK). certificate status is not CertStatus.VALID (Request.STATE_OK).
If connect returns False, the secure socket is aborted before return so If connect returns False, the secure socket is aborted before return so
there is no need to call `abort`. If connect returns True, it is up to there is no need to call `abort`. If connect returns True, it is up to the
the caller to decide whether to continue (call `proceed`) the caller to decide whether to continue (call `proceed`) the connection or
connection or abort it (call `abort`). abort it (call `abort`).
The request `state` is updated to reflect the connection state after The request `state` is updated to reflect the connection state after the
the function returns. The following list describes states related to function returns. The following list describes states related to
connection failure (False returned): connection failure (False returned):
- STATE_INVALID_URL: URL is not valid. - STATE_INVALID_URL: URL is not valid.
@ -95,8 +95,8 @@ class Request:
For all request states from now on, the `cert_validation` attribute is For all request states from now on, the `cert_validation` attribute is
updated with the result of the certificate validation. updated with the result of the certificate validation.
The following list describes states related to validation failure The following list describes states related to validation failure (False
(False returned): returned):
- STATE_ERROR_CERT: server certificate could not be validated at all. - STATE_ERROR_CERT: server certificate could not be validated at all.
- STATE_UNTRUSTED_CERT: server certificate mismatched the known - STATE_UNTRUSTED_CERT: server certificate mismatched the known
@ -117,8 +117,7 @@ class Request:
- The DER hash is compared against the fingerprint for this hostname - The DER hash is compared against the fingerprint for this hostname
*and port*; the specification does not tell much about that, but we *and port*; the specification does not tell much about that, but we
are slightly more restrictive here by adding the port in the are slightly more restrictive here by adding the port in the equation.
equation.
- The state STATE_INVALID_CERT is actually never used in Bebop because - The state STATE_INVALID_CERT is actually never used in Bebop because
of the current tendency to ignore any certificate fields and only of the current tendency to ignore any certificate fields and only
check the whole cert fingerprint. Here it is considered the same as a check the whole cert fingerprint. Here it is considered the same as a
@ -153,11 +152,6 @@ class Request:
self.state = Request.STATE_CONNECTION_FAILED self.state = Request.STATE_CONNECTION_FAILED
self.error = exc.strerror self.error = exc.strerror
return False return False
except ValueError as exc:
self.state = Request.STATE_INVALID_URL
self.error = "Some connection parameter is wrong, check again."
logging.error(f"ValueError during connection creation: {exc}")
return False
# Setup TLS. # Setup TLS.
context = Request.get_ssl_context() context = Request.get_ssl_context()

View file

@ -0,0 +1,45 @@
from bebop.metalines import RenderOptions
import unittest
from ..external import substitute_external_command
from ..page import Page
URL = "gemini://example.com"
GEMTEXT = """\
# Test page
Blablabla
=> gemini://example.com/index.gmi Link to index
=> gemini://example.com/sub/gemlog.gmi Link to gemlog
"""
PAGE = Page.from_gemtext(GEMTEXT, RenderOptions(80, "fancy", "- "))
class TestExternal(unittest.TestCase):
def test_substitute_external_command(self):
# Replace URLs occurences.
command = "gmni $u | grep $u" # Grep for a page's own URL.
result = substitute_external_command(command, URL, PAGE)
self.assertEqual(result, "gmni {u} | grep {u}".format(u=URL))
# Replace link ID's with the target URL.
command = "gmni $1 && gmni $2" # Get both links
result = substitute_external_command(command, URL, PAGE)
expected = (
"gmni gemini://example.com/index.gmi"
" && gmni gemini://example.com/sub/gemlog.gmi"
)
self.assertEqual(result, expected)
# Invalid link ID raise a ValueError.
command = "gmni $8"
with self.assertRaises(Exception):
substitute_external_command(command, URL, PAGE)
# Replace escaped $$ with literal $.
command = "grep ^iamaregex$$ | echo dollar $" # Do nothing with last.
result = substitute_external_command(command, URL, PAGE)
self.assertEqual(result, "grep ^iamaregex$ | echo dollar $")