diff --git a/README.md b/README.md index 4b24dbd..a648218 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ CLI tool to query LDAP/AD servers * JSON stream (with detailed or simplified attribute values) * CSV * Markdown table with stretched columns (for viewing in CLI/for monospaces fonts) + * HTML * Decodes certain well-known attributes (UUIDs, Timestamps, SID, userAccountControl) * Requires server to support [RFC 2696: Simple Paged Results](https://www.rfc-editor.org/rfc/rfc2696) for proper pagination * By default the first 1000 entries are shown, and it errors if there are more results diff --git a/src/ldaptool/_main.py b/src/ldaptool/_main.py index da67c45..60f081e 100644 --- a/src/ldaptool/_main.py +++ b/src/ldaptool/_main.py @@ -4,6 +4,7 @@ import argparse import csv import dataclasses import enum +import html import subprocess import sys import typing @@ -16,6 +17,13 @@ from ldaptool._utils.ldap import Result, SizeLimitExceeded class TableOutput(enum.StrEnum): MARKDOWN = "markdown" CSV = "csv" + HTML = "html" + + +def _html_escape_line(columns: typing.Sequence[str], *, cell: str = "td") -> str: + cell_s = f"<{cell}>" + cell_e = f"" + return "" + ("".join(cell_s + html.escape(col) + cell_e for col in columns)) + "\n" @dataclasses.dataclass(slots=True, kw_only=True) @@ -35,6 +43,12 @@ class Arguments(search.Arguments): ), ) table_output: typing.Optional[TableOutput] = None + html: bool = dataclasses.field( + default=False, + metadata=argclasses.arg( + help="HTML table output - requires list of attributes", + ), + ) sort: bool = dataclasses.field( default=False, metadata=argclasses.arg( @@ -45,14 +59,16 @@ class Arguments(search.Arguments): def __post_init__(self) -> None: super(Arguments, self).__post_init__() # super() not working here, unclear why. - # pick at most one in csv, (markdown) table - if [self.csv, self.table].count(True) > 1: + # pick at most one in csv, (markdown) table, html + if [self.csv, self.table, self.html].count(True) > 1: raise SystemExit("Can't use more than one table output type") if self.csv: self.table_output = TableOutput.CSV elif self.table: self.table_output = TableOutput.MARKDOWN + elif self.html: + self.table_output = TableOutput.HTML if self.sort and self.table_output is None: # default to markdown table @@ -135,11 +151,20 @@ class _Context: if self.arguments.sort: line_iterator = sorted(line_iterator) - csv_out = csv.writer(stream, lineterminator="\n") - csv_out.writerow(self.arguments.columns) + if self.arguments.table_output in [TableOutput.CSV, TableOutput.MARKDOWN]: + csv_out = csv.writer(stream, lineterminator="\n") + csv_out.writerow(self.arguments.columns) - for line in line_iterator: - csv_out.writerow(line) + for line in line_iterator: + csv_out.writerow(line) + else: + assert self.arguments.table_output == TableOutput.HTML + + stream.write("\n") + stream.write(_html_escape_line(self.arguments.columns, cell="th")) + for line in line_iterator: + stream.write(_html_escape_line(line)) + stream.write("
\n") def _ldif_or_json_output(self, search_iterator: typing.Iterable[Result], *, stream: typing.IO[str]) -> None: decoder = decode.Decoder(arguments=self.arguments)