From 23fb6fb5de470660d5b5e44ecb742cc6688fee49 Mon Sep 17 00:00:00 2001 From: Mike Cohen Date: Mon, 4 Mar 2024 13:58:25 +1000 Subject: [PATCH] Improved the text preview so it pages correctly. (#3324) --- api/download.go | 88 ++++++ .../src/components/core/api-service.jsx | 2 +- .../src/components/widgets/pagination.jsx | 68 ++++ .../components/widgets/preview_uploads.css | 9 + .../components/widgets/preview_uploads.jsx | 297 +++++++++++++----- 5 files changed, 382 insertions(+), 82 deletions(-) diff --git a/api/download.go b/api/download.go index be61059272e..4f923a68f3c 100644 --- a/api/download.go +++ b/api/download.go @@ -99,6 +99,10 @@ type vfsFileDownloadRequest struct { // If set we pad the file out. Padding bool `schema:"padding"` + + // If set we filter binary chars to reveal only text + TextFilter bool `schema:"text_filter"` + Lines int `schema:"lines"` } // URL format: /api/v1/DownloadVFSFile @@ -204,6 +208,25 @@ func vfsFileDownloadHandler() http.Handler { total_size = calculateTotalReaderSize(file) } + if request.TextFilter { + output, next_offset, err := filterData(reader_at, request) + if err != nil { + returnError(w, 500, err.Error()) + return + } + + w.Header().Set("Content-Disposition", "attachment; "+ + sanitizeFilenameForAttachment(filename)) + w.Header().Set("Content-Type", + detectMime(output, request.DetectMime)) + w.Header().Set("Content-Range", + fmt.Sprintf("bytes %d-%d/%d", request.Offset, next_offset, total_size)) + w.WriteHeader(200) + + _, _ = w.Write(output) + return + } + emitContentLength(w, int(request.Offset), int(request.Length), total_size) offset := request.Offset @@ -265,6 +288,71 @@ func vfsFileDownloadHandler() http.Handler { }) } +// Read data from offset and filter it until the requested number of +// lines is found. This produces text only output, aka "strings" +func filterData(reader_at io.ReaderAt, + request vfsFileDownloadRequest) ( + output []byte, next_offset int64, err error) { + + lines := 0 + required_lines := request.Lines + if required_lines == 0 { + required_lines = 25 + } + offset := request.Offset + + buf := pool.Get().([]byte) + defer pool.Put(buf) + + // This is a safety mechanism in case the file is mostly 0 + total_read := 0 + + for { + if total_read > 10*1024*1024 { + break + } + + n, err := reader_at.ReadAt(buf, offset) + if err != nil && err != io.EOF { + return nil, 0, err + } + + if n <= 0 { + break + } + + total_read += n + + // Read the buffer and filter it collecting only printable + // chars. + for i := 0; i < n; i++ { + c := buf[i] + switch c { + case 0: + continue + + case '\n': + lines++ + if request.Lines <= lines { + return output, offset + int64(i), nil + } + fallthrough + + default: + if c >= 0x20 && c < 0x7f || + c == 10 || c == 13 || c == 9 { + output = append(output, c) + } else { + output = append(output, '.') + } + } + } + offset += int64(n) + } + + return output, offset, nil +} + func detectMime(buffer []byte, detect_mime bool) string { if detect_mime && len(buffer) > 8 { if 0 == bytes.Compare( diff --git a/gui/velociraptor/src/components/core/api-service.jsx b/gui/velociraptor/src/components/core/api-service.jsx index d8a39259c12..60003d00a22 100644 --- a/gui/velociraptor/src/components/core/api-service.jsx +++ b/gui/velociraptor/src/components/core/api-service.jsx @@ -178,7 +178,7 @@ const get_blob = function(url, params, cancel_token) { var reader = new FileReader(); reader.onloadend = function() { - resolve(reader.result); + resolve({data: reader.result, blob: blob}); }; reader.readAsArrayBuffer(blob.data); diff --git a/gui/velociraptor/src/components/widgets/pagination.jsx b/gui/velociraptor/src/components/widgets/pagination.jsx index 94862f8dd66..56b4f4343a0 100644 --- a/gui/velociraptor/src/components/widgets/pagination.jsx +++ b/gui/velociraptor/src/components/widgets/pagination.jsx @@ -6,6 +6,74 @@ import T from '../i8n/i8n.jsx'; import classNames from "classnames"; +export class TextPaginationControl extends React.Component { + static propTypes = { + base_offset: PropTypes.number, + setBaseOffset: PropTypes.func, + } + + state = { + next_offset: 0, + } + + render() { + return ( + + this.gotoPage(0)}/> + this.gotoPage(this.props.current_page-1)}/> + + { + let goto_offset = e.target.value; + this.setState({goto_offset: goto_offset}); + + if (goto_offset === "") { + return; + } + + let base_offset = parseInt(goto_offset); + if (isNaN(base_offset)) { + this.setState({goto_error: true}); + return; + } + this.setState({goto_error: false}); + + if (base_offset > this.props.total_size) { + goto_offset = this.props.total_size; + base_offset = this.props.total_size; + this.setState({ + goto_offset: goto_offset, + }); + } + this.setHighlight(base_offset); + + let page = parseInt(base_offset/this.props.page_size); + this.props.onPageChange(page); + }}/> + this.gotoPage(this.props.current_page+1)}/> + this.gotoPage(last_page)}/> + + ); + } +} + + export default class HexPaginationControl extends React.Component { static propTypes = { total_pages: PropTypes.number, diff --git a/gui/velociraptor/src/components/widgets/preview_uploads.css b/gui/velociraptor/src/components/widgets/preview_uploads.css index 1e8814ea41a..f8e2072bde5 100644 --- a/gui/velociraptor/src/components/widgets/preview_uploads.css +++ b/gui/velociraptor/src/components/widgets/preview_uploads.css @@ -45,3 +45,12 @@ input.goto-invalid.form-control:focus { .preview-json { margin-top: 45px; } + +.text-paginator { + text-wrap: nowrap; +} + +.textdump { + overflow-y: auto; + height: calc(100vh - 360px); +} diff --git a/gui/velociraptor/src/components/widgets/preview_uploads.jsx b/gui/velociraptor/src/components/widgets/preview_uploads.jsx index dc3615cae29..84d262e4e81 100644 --- a/gui/velociraptor/src/components/widgets/preview_uploads.jsx +++ b/gui/velociraptor/src/components/widgets/preview_uploads.jsx @@ -20,7 +20,10 @@ import T from '../i8n/i8n.jsx'; import VeloValueRenderer from '../utils/value.jsx'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; +import Form from 'react-bootstrap/Form'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import Pagination from 'react-bootstrap/Pagination'; +import classNames from "classnames"; // https://en.wikipedia.org/wiki/List_of_file_signatures const patterns = [ @@ -57,11 +60,13 @@ function checkMime(buffer) { return ""; } -class HexViewTab extends React.PureComponent { +class TextViewTab extends React.Component { static propTypes = { params: PropTypes.object, url: PropTypes.string, size: PropTypes.number, + base_offset: PropTypes.number, + setBaseOffset: PropTypes.func, } componentDidMount = () => { @@ -75,27 +80,18 @@ class HexViewTab extends React.PureComponent { componentDidUpdate = (prevProps, prevState, rootNode) => { if (!_.isEqual(prevProps.params, this.props.params) || + !_.isEqual(prevProps.base_offset, this.props.base_offset) || !_.isEqual(prevState.page, this.state.page) || !_.isEqual(prevState.columns, this.state.columns)) { this.fetchPage_(this.state.page); }; } - state = { - // The offset in the file where this screen views - base_offset: 0, - page: 0, - rows: 25, - columns: 0x10, - view: undefined, - loading: true, - textview_only: false, - highlights: {}, - highlight_version: 0, - version: 0, - } - fetchPage_ = (page, ondone) => { + if (this.state.goto_error) { + return; + } + let params = Object.assign({}, this.props.params); this.source.cancel(); @@ -103,19 +99,29 @@ class HexViewTab extends React.PureComponent { // read a bit more than we need to so the text view looks a // bit more full. - params.length = this.state.rows * this.state.columns * 2; - params.offset = page * this.state.rows * this.state.columns; - - api.get_blob(this.props.url, params, this.source.token).then(buffer=>{ - const view = new Uint8Array(buffer); - this.setState({ - base_offset: params.offset, - view: view, - version: this.state.version+1, - rawdata: this.parseFileContentToTextRepresentation_(view), - loading: false}); - if(ondone) {ondone();}; - }); + params.lines = 40; + params.offset = this.props.base_offset || 0; + params.text_filter = true; + + api.get_blob(this.props.url, params, this.source.token).then( + response=>{ + let content_range = response.blob && response.blob.headers && + response.blob.headers["content-range"]; + if(content_range) { + const matches = content_range.match(/^(\w+) ((\d+)-(\d+)|\*)\/(\d+|\*)$/); + const [, unit, , start, end, size] = matches; + this.setState({next_offset: Number(end || 0), total_size: Number(size || 0)}); + } + + const view = new Uint8Array(response.data); + this.setState({ + base_offset: params.offset, + view: view, + version: this.state.version+1, + rawdata: this.parseFileContentToTextRepresentation_(view), + loading: false}); + if(ondone) {ondone();}; + }); this.setState({loading: true}); } @@ -138,6 +144,141 @@ class HexViewTab extends React.PureComponent { return rawdata; }; + state = { + rawdata: "", + next_offset: 0, + total_size: 0, + } + + render() { + return + + + + + this.props.setBaseOffset(0)}/> + + { + let goto_offset = e.target.value; + let old_goto_offset = this.state.goto_offset; + + if (goto_offset === "") { + this.props.setBaseOffset(0); + this.setState({goto_offset: "", goto_error: false}); + return; + } + + let base_offset = Number(goto_offset); + if (isNaN(base_offset)) { + this.setState({goto_offset: old_goto_offset}); + return; + } + + if (base_offset > this.state.total_size) { + goto_offset = this.state.total_size; + base_offset = this.state.total_size; + goto_offset = old_goto_offset; + } + this.props.setBaseOffset(goto_offset); + this.setState({goto_offset: goto_offset, goto_error: false}); + }}/> + + this.props.setBaseOffset(this.state.next_offset)}/> + + + + + +
+ {this.state.rawdata} +
+ +
+
; + }; +} + +class HexViewTab extends React.Component { + static propTypes = { + params: PropTypes.object, + url: PropTypes.string, + size: PropTypes.number, + + // The offset in the file where this screen views + base_offset: PropTypes.number, + setBaseOffset: PropTypes.func, + } + + componentDidMount = () => { + this.source = CancelToken.source(); + this.fetchPage_(0); + } + + componentWillUnmount() { + this.source.cancel("unmounted"); + } + + componentDidUpdate = (prevProps, prevState, rootNode) => { + if (!_.isEqual(prevProps.params, this.props.params) || + !_.isEqual(prevProps.base_offset, this.props.base_offset) || + !_.isEqual(prevState.page, this.state.page) || + !_.isEqual(prevState.columns, this.state.columns)) { + this.fetchPage_(this.state.page); + }; + } + + state = { + page: 0, + rows: 25, + columns: 0x10, + view: undefined, + loading: true, + highlights: {}, + highlight_version: 0, + version: 0, + } + + fetchPage_ = (page, ondone) => { + let params = Object.assign({}, this.props.params); + + this.source.cancel(); + this.source = CancelToken.source(); + + params.length = this.state.rows * this.state.columns; + params.offset = this.props.base_offset; + + api.get_blob(this.props.url, params, this.source.token).then( + response=>{ + const view = new Uint8Array(response.data); + this.setState({ + base_offset: params.offset, + view: view, + version: this.state.version+1, + loading: false}); + if(ondone) {ondone();}; + }); + this.setState({loading: true}); + } + render() { var chunkSize = this.state.rows * this.state.columns; let total_size = this.props.size || 0; @@ -147,16 +288,7 @@ class HexViewTab extends React.PureComponent { - - - - + { this.fetchPage_(page, ()=>{ + this.props.setBaseOffset(page * chunkSize); this.setState({ - base_offset: page * chunkSize, page: page, }); }); @@ -190,8 +322,8 @@ class HexViewTab extends React.PureComponent { version={this.state.version} onPageChange={page=>{ this.fetchPage_(page, ()=>{ + this.props.setBaseOffset(page * chunkSize); this.setState({ - base_offset: page * chunkSize, page: page, }); }); @@ -206,40 +338,30 @@ class HexViewTab extends React.PureComponent { - { this.state.textview_only ? - -
- {this.state.rawdata} -
- - : - <> - -
- this.setState({columns: v})} - columns={this.state.columns} - - // The data that will be rendered - byte_array={this.state.view} - version={this.state.version} /> -
- - - } + +
+ this.setState({columns: v})} + columns={this.state.columns} + + // The data that will be rendered + byte_array={this.state.view} + version={this.state.version} /> +
+
); } } -class InspectDialog extends React.PureComponent { +class InspectDialog extends React.Component { static propTypes = { params: PropTypes.object, url: PropTypes.string, @@ -249,7 +371,8 @@ class InspectDialog extends React.PureComponent { } state = { - tab: "overview", + tab: "hex", + base_offset: 0, } render() { @@ -266,12 +389,23 @@ class InspectDialog extends React.PureComponent { this.setState({tab: tab})}> - - { this.state.tab === "overview" && + + { this.state.tab === "hex" && } + base_offset={this.state.base_offset} + setBaseOffset={x=>this.setState({base_offset: x})} + size={this.props.size}/> + } + + + { this.state.tab === "text" && + this.setState({base_offset: x})} + size={this.props.size}/> + } { this.state.tab === "details" && @@ -374,15 +508,16 @@ export default class PreviewUpload extends Component { this.setState({url: url, params: params, error: false, loading: true}); - api.get_blob(url, params, this.source.token).then(buffer=>{ - if(buffer.error) { - this.setState({error: true}); + api.get_blob(url, params, this.source.token).then( + response=>{ + if(response.data && response.data.error) { + this.setState({error: true}); - } else { - const view = new Uint8Array(buffer); - this.setState({view: view, error: false}); - } - }); + } else { + const view = new Uint8Array(response.data); + this.setState({view: view, error: false}); + } + }); }; uintToString = (uintArray) => {