osintui/ui/
mod.rs

1pub mod shodan;
2pub mod util;
3pub mod virustotal;
4
5use super::{
6    app::{ActiveBlock, App, RouteId},
7    banner::BANNER,
8};
9use crate::ui::{
10    shodan::{draw_shodan, draw_shodan_geo_lookup},
11    util::get_color,
12    virustotal::{draw_virustotal_community, draw_virustotal_details, draw_virustotal_detection},
13};
14use tui::{
15    backend::Backend,
16    layout::{Alignment, Constraint, Direction, Layout, Rect},
17    style::{Color, Modifier, Style},
18    text::{Span, Spans, Text},
19    widgets::{
20        Block, BorderType, Borders, List, ListItem, ListState, Paragraph, Row, Table, Tabs, Wrap,
21    },
22    Frame,
23};
24
25#[derive(Copy, Clone, Debug)]
26enum MenuItem {
27    Home,
28}
29
30impl From<MenuItem> for usize {
31    fn from(input: MenuItem) -> usize {
32        match input {
33            MenuItem::Home => 0,
34        }
35    }
36}
37
38#[derive(PartialEq)]
39pub enum ColumnId {
40    None,
41}
42
43impl Default for ColumnId {
44    fn default() -> Self {
45        ColumnId::None
46    }
47}
48
49#[derive(Default)]
50pub struct TableHeaderItem<'a> {
51    text: &'a str,
52    width: u16,
53}
54
55pub struct TableHeader<'a> {
56    items: Vec<TableHeaderItem<'a>>,
57}
58
59pub struct TableItem {
60    format: Vec<String>,
61}
62
63pub fn draw_main_layout<B>(f: &mut Frame<B>, app: &App)
64where
65    B: Backend,
66{
67    let parent_layout = Layout::default()
68        .direction(Direction::Vertical)
69        .margin(2)
70        .constraints([Constraint::Length(3), Constraint::Min(2)].as_ref())
71        .split(f.size());
72
73    draw_menu_search_help_box(f, app, parent_layout[0]);
74    draw_routes(f, app, parent_layout[1]);
75}
76
77pub fn draw_routes<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
78where
79    B: Backend,
80{
81    let chunks = Layout::default()
82        .direction(Direction::Horizontal)
83        .constraints([Constraint::Percentage(100)].as_ref())
84        .split(layout_chunk);
85
86    let current_route = app.get_current_route();
87
88    match current_route.id {
89        RouteId::Search => {}
90        RouteId::Home => {
91            draw_home(f, app, chunks[0]);
92        }
93        RouteId::SearchResult => {
94            draw_search_result_page(f, app, chunks[0]);
95        }
96        RouteId::VirustotalDetection => {
97            draw_virustotal_detection(f, app, chunks[0]);
98        }
99        RouteId::VirustotalDetails => {
100            draw_virustotal_details(f, app, chunks[0]);
101        }
102        RouteId::VirustotalCommunity => {
103            draw_virustotal_community(f, app, chunks[0]);
104        }
105        RouteId::Unloaded => {
106            draw_unloaded(f, app, chunks[0]);
107        }
108        RouteId::Shodan => {
109            draw_shodan(f, app, chunks[0]);
110        }
111        RouteId::ShodanGeoLookup => {
112            draw_shodan_geo_lookup(f, app, chunks[0]);
113        }
114        RouteId::Error => {} // This is handled as a "full screen" route in main.rs
115    };
116}
117
118pub fn draw_menu_search_help_box<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
119where
120    B: Backend,
121{
122    // Check for the width and change the contraints accordingly
123    let chunks = Layout::default()
124        .direction(Direction::Horizontal)
125        .constraints(
126            [
127                Constraint::Percentage(50),
128                Constraint::Percentage(30),
129                Constraint::Percentage(20),
130            ]
131            .as_ref(),
132        )
133        .split(layout_chunk);
134
135    let current_route = app.get_current_route();
136
137    let highlight_state = (
138        current_route.active_block == ActiveBlock::Input,
139        current_route.hovered_block == ActiveBlock::Input,
140    );
141
142    let input_string: String = app.input.iter().collect();
143    let lines = Text::from((&input_string).as_str());
144    let input = Paragraph::new(lines).block(
145        Block::default()
146            .borders(Borders::ALL)
147            .title(Span::styled("Search", get_color(highlight_state))),
148    );
149
150    f.render_widget(input, chunks[0]);
151
152    let menu = vec!["Home", "Shodan", "VirusTotal", "Quit"]
153        .iter()
154        .map(|t| {
155            let (first, rest) = t.split_at(1);
156            Spans::from(vec![
157                Span::styled(
158                    first,
159                    Style::default()
160                        .fg(Color::Yellow)
161                        .add_modifier(Modifier::UNDERLINED),
162                ),
163                Span::styled(rest, Style::default().fg(Color::White)),
164            ])
165        })
166        .collect();
167
168    let active_menu_item = MenuItem::Home;
169    let tabs = Tabs::new(menu)
170        .select(active_menu_item.into())
171        .block(Block::default().title("Menu").borders(Borders::ALL))
172        .style(Style::default().fg(Color::White))
173        .highlight_style(Style::default().fg(Color::Yellow))
174        .divider(Span::raw("|"));
175
176    f.render_widget(tabs, chunks[1]);
177
178    let help_block_text = if app.is_loading {
179        (app.user_config.theme.hint, "Loading...")
180    } else if app.is_input_error {
181        (app.user_config.theme.hint, "ERR: Not valid.")
182    } else {
183        (app.user_config.theme.inactive, "Waiting for input...")
184    };
185
186    let block = Block::default()
187        .title(Span::styled(
188            "Status",
189            Style::default().fg(help_block_text.0),
190        ))
191        .borders(Borders::ALL)
192        .border_style(Style::default().fg(help_block_text.0));
193
194    let lines = Text::from(help_block_text.1);
195    let help = Paragraph::new(lines)
196        .block(block)
197        .style(Style::default().fg(help_block_text.0));
198    f.render_widget(help, chunks[2]);
199}
200
201pub fn draw_home<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
202where
203    B: Backend,
204{
205    // Check for the width and change the contraints accordingly
206    let chunks = Layout::default()
207        .direction(Direction::Horizontal)
208        .constraints([Constraint::Percentage(80), Constraint::Percentage(20)].as_ref())
209        .split(layout_chunk);
210
211    draw_welcome_page(f, app, chunks[0]);
212    draw_integrations(f, app, chunks[1]);
213}
214
215pub fn draw_welcome_page<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
216where
217    B: Backend,
218{
219    // Check for the width and change the contraints accordingly
220    let chunks = Layout::default()
221        .direction(Direction::Vertical)
222        .constraints([Constraint::Length(7), Constraint::Length(93)].as_ref())
223        .margin(2)
224        .split(layout_chunk);
225
226    let current_route = app.get_current_route();
227    let highlight_state = (
228        current_route.active_block == ActiveBlock::Home,
229        current_route.hovered_block == ActiveBlock::Home,
230    );
231
232    let welcome = Block::default()
233        .title(Span::styled("Welcome!", get_color(highlight_state)))
234        .borders(Borders::ALL)
235        .border_style(get_color(highlight_state));
236    f.render_widget(welcome, layout_chunk);
237
238    // Banner text with correct styling
239    let mut top_text = Text::from(BANNER);
240    top_text.patch_style(Style::default().fg(app.user_config.theme.banner));
241
242    // // Contains the banner
243    let top_text = Paragraph::new(top_text)
244        .style(Style::default().fg(Color::LightRed))
245        .alignment(Alignment::Center)
246        .block(Block::default());
247
248    f.render_widget(top_text, chunks[0]);
249
250    let home = Paragraph::new(vec![
251        Spans::from(vec![Span::raw("")]),
252        Spans::from(vec![Span::raw("'/' to search")]),
253        Spans::from(vec![Span::raw("'s' to access shodan")]),
254        Spans::from(vec![Span::raw("'v' to access virustotal")]),
255    ])
256    .style(Style::default().fg(app.user_config.theme.text))
257    .alignment(Alignment::Center)
258    .block(Block::default())
259    .wrap(Wrap { trim: false });
260
261    f.render_widget(home, chunks[1]);
262}
263
264pub fn draw_integrations<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
265where
266    B: Backend,
267{
268    let api_view = Paragraph::new(vec![
269        Spans::from(vec![Span::raw("")]),
270        Spans::from(vec![Span::raw(format!(
271            " {} Shodan",
272            match app.client_config.keys.shodan.is_empty() {
273                true => "❌",
274                false => "✅",
275            }
276        ))]),
277        Spans::from(vec![Span::raw("")]),
278        Spans::from(vec![Span::raw(format!(
279            " {} Virustotal",
280            match app.client_config.keys.virustotal.is_empty() {
281                true => "❌",
282                false => "✅",
283            }
284        ))]),
285    ])
286    .alignment(Alignment::Left)
287    .block(
288        Block::default()
289            .borders(Borders::ALL)
290            .style(Style::default().fg(Color::White))
291            .title("Integrations")
292            .border_type(BorderType::Plain),
293    );
294    f.render_widget(api_view, layout_chunk);
295}
296
297pub fn draw_search_result_page<B>(f: &mut Frame<B>, _app: &App, layout_chunk: Rect)
298where
299    B: Backend,
300{
301    let home = Paragraph::new(vec![
302        Spans::from(vec![Span::raw("")]),
303        Spans::from(vec![Span::raw("Lookup complete!")]),
304        Spans::from(vec![Span::raw("")]),
305        Spans::from(vec![Span::raw("'/' to search")]),
306        Spans::from(vec![Span::raw("'s' to access shodan")]),
307        Spans::from(vec![Span::raw("'v' to access virustotal")]),
308    ])
309    .alignment(Alignment::Center)
310    .block(
311        Block::default()
312            .borders(Borders::ALL)
313            .style(Style::default().fg(Color::White))
314            .title("Home")
315            .border_type(BorderType::Plain),
316    );
317    f.render_widget(home, layout_chunk);
318}
319
320pub fn draw_error_screen<B>(f: &mut Frame<B>, app: &App)
321where
322    B: Backend,
323{
324    let chunks = Layout::default()
325        .direction(Direction::Vertical)
326        .constraints([Constraint::Percentage(100)].as_ref())
327        .margin(5)
328        .split(f.size());
329
330    let error_text = vec![
331        Spans::from(vec![
332            Span::raw("Api response: "),
333            Span::styled(
334                &app.api_error,
335                Style::default().fg(app.user_config.theme.error_text),
336            ),
337        ]),
338        Spans::from(Span::styled(
339            "\nPress <Esc> to return",
340            Style::default().fg(app.user_config.theme.inactive),
341        )),
342    ];
343
344    let error_paragraph = Paragraph::new(error_text)
345        .wrap(Wrap { trim: true })
346        .style(Style::default().fg(app.user_config.theme.text))
347        .block(
348            Block::default()
349                .borders(Borders::ALL)
350                .title(Span::styled(
351                    "Error",
352                    Style::default().fg(app.user_config.theme.error_border),
353                ))
354                .border_style(Style::default().fg(app.user_config.theme.error_border)),
355        );
356    f.render_widget(error_paragraph, chunks[0]);
357}
358
359fn draw_selectable_list<B, S>(
360    f: &mut Frame<B>,
361    app: &App,
362    layout_chunk: Rect,
363    title: &str,
364    items: &[S],
365    highlight_state: (bool, bool),
366    selected_index: Option<usize>,
367) where
368    B: Backend,
369    S: std::convert::AsRef<str>,
370{
371    let mut state = ListState::default();
372    state.select(selected_index);
373
374    let items: Vec<ListItem> = items
375        .iter()
376        .map(|i| ListItem::new(Span::raw(i.as_ref())))
377        .collect();
378
379    let list = List::new(items)
380        .block(
381            Block::default()
382                .title(Span::styled(title, get_color(highlight_state)))
383                .borders(Borders::ALL)
384                .border_style(get_color(highlight_state)),
385        )
386        .style(Style::default().fg(app.user_config.theme.text))
387        .highlight_style(get_color(highlight_state).add_modifier(Modifier::BOLD));
388
389    f.render_stateful_widget(list, layout_chunk, &mut state);
390}
391
392fn draw_table<B>(
393    f: &mut Frame<B>,
394    app: &App,
395    layout_chunk: Rect,
396    table_layout: (&str, &TableHeader), // (title, header colums)
397    items: &[TableItem], // The nested vector must have the same length as the `header_columns`
398    selected_index: usize,
399    highlight_state: (bool, bool),
400) where
401    B: Backend,
402{
403    let selected_style = get_color(highlight_state).add_modifier(Modifier::BOLD);
404
405    let (title, header) = table_layout;
406
407    // Make sure that the selected item is visible on the page. Need to add some rows of padding
408    // to chunk height for header and header space to get a true table height
409    let padding = 5;
410    let offset = layout_chunk
411        .height
412        .checked_sub(padding)
413        .and_then(|height| selected_index.checked_sub(height as usize))
414        .unwrap_or(0);
415
416    let rows = items.iter().skip(offset).enumerate().map(|(i, item)| {
417        let formatted_row = item.format.clone();
418        let mut style = Style::default().fg(app.user_config.theme.text);
419
420        // Next check if the item is under selection.
421        if Some(i) == selected_index.checked_sub(offset) {
422            style = selected_style;
423        }
424
425        // Return row styled data
426        Row::new(formatted_row).style(style)
427    });
428
429    let widths = header
430        .items
431        .iter()
432        .map(|h| Constraint::Length(h.width))
433        .collect::<Vec<tui::layout::Constraint>>();
434
435    let table = Table::new(rows)
436        .header(
437            Row::new(header.items.iter().map(|h| h.text))
438                .style(Style::default().fg(app.user_config.theme.header)),
439        )
440        .block(
441            Block::default()
442                .borders(Borders::ALL)
443                .style(Style::default().fg(app.user_config.theme.text))
444                .title(Span::styled(title, get_color(highlight_state)))
445                .border_style(get_color(highlight_state)),
446        )
447        .style(Style::default().fg(app.user_config.theme.text))
448        .widths(&widths);
449
450    f.render_widget(table, layout_chunk);
451}
452
453pub fn draw_unloaded<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
454where
455    B: Backend,
456{
457    let plugin = match app.get_current_route().active_block {
458        ActiveBlock::VirustotalUnloaded => "Virustotal",
459        ActiveBlock::ShodanUnloaded => "Shodan",
460        _ => "",
461    };
462
463    let text = vec![
464        Spans::from(Span::styled(
465            format!("\nThe {} plugin is not currently loaded.", plugin),
466            Style::default().fg(app.user_config.theme.inactive),
467        )),
468        Spans::from(Span::styled(
469            "\nPress <Esc> to return",
470            Style::default().fg(app.user_config.theme.inactive),
471        )),
472    ];
473
474    let paragraph = Paragraph::new(text)
475        .wrap(Wrap { trim: true })
476        .style(Style::default().fg(app.user_config.theme.text))
477        .block(
478            Block::default()
479                .borders(Borders::ALL)
480                .title(Span::styled(
481                    "Error",
482                    Style::default().fg(app.user_config.theme.error_border),
483                ))
484                .border_style(Style::default().fg(app.user_config.theme.error_border)),
485        );
486    f.render_widget(paragraph, layout_chunk);
487}