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 => {} };
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 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 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 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 let mut top_text = Text::from(BANNER);
240 top_text.patch_style(Style::default().fg(app.user_config.theme.banner));
241
242 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), items: &[TableItem], 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 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 if Some(i) == selected_index.checked_sub(offset) {
422 style = selected_style;
423 }
424
425 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}