feat: added remote access via tui and api key 40fe840a
Steve · 2026-02-19 06:55 6 file(s) · +496 −58
Cargo.toml +3 −0
24 24
crossterm = "0.28"
25 25
arboard = "3"
26 26
syntect = "5"
27 +
reqwest = { version = "0.12", features = ["json", "blocking"] }
28 +
serde_json = "1"
29 +
clap = { version = "4", features = ["derive", "env"] }
src/backend.rs (added) +120 −0
1 +
use crate::db::{self, Db, Snippet};
2 +
use std::fmt;
3 +
4 +
pub enum BackendError {
5 +
    NotFound,
6 +
    Unauthorized(String),
7 +
    Network(String),
8 +
}
9 +
10 +
impl fmt::Display for BackendError {
11 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
12 +
        match self {
13 +
            BackendError::NotFound => write!(f, "Not found"),
14 +
            BackendError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
15 +
            BackendError::Network(msg) => write!(f, "Network error: {}", msg),
16 +
        }
17 +
    }
18 +
}
19 +
20 +
pub enum Backend {
21 +
    Local {
22 +
        db: Db,
23 +
    },
24 +
    Remote {
25 +
        base_url: String,
26 +
        api_key: Option<String>,
27 +
        client: reqwest::blocking::Client,
28 +
    },
29 +
}
30 +
31 +
impl Backend {
32 +
    pub fn local() -> Self {
33 +
        Backend::Local { db: db::init_db() }
34 +
    }
35 +
36 +
    pub fn remote(base_url: String, api_key: Option<String>) -> Self {
37 +
        Backend::Remote {
38 +
            base_url,
39 +
            api_key,
40 +
            client: reqwest::blocking::Client::new(),
41 +
        }
42 +
    }
43 +
44 +
    pub fn list_snippets(&self) -> Result<Vec<Snippet>, BackendError> {
45 +
        match self {
46 +
            Backend::Local { db } => Ok(db::get_all_snippets(db)),
47 +
            Backend::Remote {
48 +
                base_url,
49 +
                api_key,
50 +
                client,
51 +
            } => {
52 +
                let mut req = client.get(format!("{}/api/snippets", base_url));
53 +
                if let Some(key) = api_key {
54 +
                    req = req.header("x-api-key", key);
55 +
                }
56 +
                let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?;
57 +
                match resp.status().as_u16() {
58 +
                    200 => resp
59 +
                        .json::<Vec<Snippet>>()
60 +
                        .map_err(|e| BackendError::Network(e.to_string())),
61 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
62 +
                    403 => Err(BackendError::Unauthorized("No API key configured on server".into())),
63 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
64 +
                }
65 +
            }
66 +
        }
67 +
    }
68 +
69 +
    pub fn create_snippet(&self, name: &str, content: &str) -> Result<Snippet, BackendError> {
70 +
        match self {
71 +
            Backend::Local { db } => Ok(db::create_snippet(db, name, content)),
72 +
            Backend::Remote {
73 +
                base_url,
74 +
                api_key,
75 +
                client,
76 +
            } => {
77 +
                let mut req = client
78 +
                    .post(format!("{}/api/snippets", base_url))
79 +
                    .json(&serde_json::json!({"name": name, "content": content}));
80 +
                if let Some(key) = api_key {
81 +
                    req = req.header("x-api-key", key);
82 +
                }
83 +
                let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?;
84 +
                match resp.status().as_u16() {
85 +
                    201 => resp
86 +
                        .json::<Snippet>()
87 +
                        .map_err(|e| BackendError::Network(e.to_string())),
88 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
89 +
                    403 => Err(BackendError::Unauthorized("No API key configured on server".into())),
90 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
91 +
                }
92 +
            }
93 +
        }
94 +
    }
95 +
96 +
    pub fn delete_snippet(&self, short_id: &str) -> Result<bool, BackendError> {
97 +
        match self {
98 +
            Backend::Local { db } => Ok(db::delete_snippet_by_short_id(db, short_id)),
99 +
            Backend::Remote {
100 +
                base_url,
101 +
                api_key,
102 +
                client,
103 +
            } => {
104 +
                let mut req =
105 +
                    client.delete(format!("{}/api/snippets/{}", base_url, short_id));
106 +
                if let Some(key) = api_key {
107 +
                    req = req.header("x-api-key", key);
108 +
                }
109 +
                let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?;
110 +
                match resp.status().as_u16() {
111 +
                    200 => Ok(true),
112 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
113 +
                    403 => Err(BackendError::Unauthorized("No API key configured on server".into())),
114 +
                    404 => Ok(false),
115 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
116 +
                }
117 +
            }
118 +
        }
119 +
    }
120 +
}
src/bin/tui.rs +304 −54
1 1
use arboard::Clipboard;
2 -
use crossterm::event::{self, Event, KeyCode};
2 +
use clap::Parser;
3 +
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
3 4
use ratatui::{
4 5
    DefaultTerminal,
5 6
    layout::{Constraint, Layout},
7 8
    text::{Line, Span, Text},
8 9
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Widget},
9 10
};
10 -
use sipp_rust::db::{self, Snippet};
11 +
use sipp_rust::backend::Backend;
12 +
use sipp_rust::db::Snippet;
13 +
use std::io::Cursor;
11 14
use std::time::{Duration, Instant};
12 15
use syntect::easy::HighlightLines;
13 16
use syntect::highlighting::Theme;
14 17
use syntect::parsing::SyntaxSet;
15 18
use syntect::util::LinesWithEndings;
16 -
use std::io::Cursor;
19 +
20 +
#[derive(Parser)]
21 +
#[command(name = "sipp-tui", about = "TUI client for sipp snippets")]
22 +
struct Cli {
23 +
    /// Remote server URL (e.g. http://localhost:3000)
24 +
    #[arg(short, long, env = "SIPP_REMOTE_URL")]
25 +
    remote: Option<String>,
26 +
27 +
    /// API key for authenticated operations
28 +
    #[arg(short = 'k', long, env = "SIPP_API_KEY")]
29 +
    api_key: Option<String>,
30 +
}
17 31
18 32
enum Focus {
19 33
    List,
20 34
    Content,
35 +
    CreateName,
36 +
    CreateContent,
21 37
}
22 38
23 39
struct App {
30 46
    show_help: bool,
31 47
    syntax_set: SyntaxSet,
32 48
    theme: Theme,
49 +
    create_name: String,
50 +
    create_content: String,
51 +
    is_remote: bool,
33 52
}
34 53
35 54
impl App {
36 -
    fn new(snippets: Vec<Snippet>) -> Self {
55 +
    fn new(snippets: Vec<Snippet>, is_remote: bool) -> Self {
37 56
        let mut list_state = ListState::default();
38 57
        if !snippets.is_empty() {
39 58
            list_state.select(Some(0));
53 72
            show_help: false,
54 73
            syntax_set,
55 74
            theme,
75 +
            create_name: String::new(),
76 +
            create_content: String::new(),
77 +
            is_remote,
56 78
        }
57 79
    }
58 80
105 127
        }
106 128
    }
107 129
108 -
    fn delete_selected(&mut self, db: &sipp_rust::db::Db) {
130 +
    fn delete_selected(&mut self, backend: &Backend) {
109 131
        if let Some(selected_index) = self.list_state.selected() {
110 132
            if let Some(snippet) = self.snippets.get(selected_index) {
111 -
                // Delete from database
112 -
                if sipp_rust::db::delete_snippet_by_short_id(db, &snippet.short_id) {
113 -
                    // Remove from local vector
114 -
                    self.snippets.remove(selected_index);
133 +
                let short_id = snippet.short_id.clone();
134 +
                match backend.delete_snippet(&short_id) {
135 +
                    Ok(true) => {
136 +
                        self.snippets.remove(selected_index);
137 +
                        if self.snippets.is_empty() {
138 +
                            self.list_state.select(None);
139 +
                        } else if selected_index >= self.snippets.len() {
140 +
                            self.list_state.select(Some(self.snippets.len() - 1));
141 +
                        } else {
142 +
                            self.list_state.select(Some(selected_index));
143 +
                        }
144 +
                        self.status_message = Some(("Deleted!".to_string(), Instant::now()));
145 +
                    }
146 +
                    Ok(false) => {
147 +
                        self.status_message =
148 +
                            Some(("Snippet not found".to_string(), Instant::now()));
149 +
                    }
150 +
                    Err(e) => {
151 +
                        self.status_message = Some((e.to_string(), Instant::now()));
152 +
                    }
153 +
                }
154 +
            }
155 +
        }
156 +
    }
115 157
116 -
                    // Adjust selection after deletion
117 -
                    if self.snippets.is_empty() {
118 -
                        self.list_state.select(None);
119 -
                    } else if selected_index >= self.snippets.len() {
158 +
    fn refresh(&mut self, backend: &Backend) {
159 +
        match backend.list_snippets() {
160 +
            Ok(snippets) => {
161 +
                self.snippets = snippets;
162 +
                if self.snippets.is_empty() {
163 +
                    self.list_state.select(None);
164 +
                } else {
165 +
                    let idx = self.list_state.selected().unwrap_or(0);
166 +
                    if idx >= self.snippets.len() {
120 167
                        self.list_state.select(Some(self.snippets.len() - 1));
121 -
                    } else {
122 -
                        self.list_state.select(Some(selected_index));
123 168
                    }
169 +
                }
170 +
                self.status_message = Some(("Refreshed!".to_string(), Instant::now()));
171 +
            }
172 +
            Err(e) => {
173 +
                self.status_message = Some((e.to_string(), Instant::now()));
174 +
            }
175 +
        }
176 +
    }
124 177
125 -
                    self.status_message = Some(("Deleted!".to_string(), Instant::now()));
126 -
                } else {
127 -
                    self.status_message = Some(("Failed to delete!".to_string(), Instant::now()));
128 -
                }
178 +
    fn start_create(&mut self) {
179 +
        self.create_name.clear();
180 +
        self.create_content.clear();
181 +
        self.focus = Focus::CreateName;
182 +
    }
183 +
184 +
    fn save_create(&mut self, backend: &Backend) {
185 +
        if self.create_name.trim().is_empty() {
186 +
            self.status_message = Some(("Name cannot be empty".to_string(), Instant::now()));
187 +
            return;
188 +
        }
189 +
        match backend.create_snippet(&self.create_name, &self.create_content) {
190 +
            Ok(snippet) => {
191 +
                self.snippets.insert(0, snippet);
192 +
                self.list_state.select(Some(0));
193 +
                self.status_message = Some(("Created!".to_string(), Instant::now()));
194 +
                self.focus = Focus::List;
195 +
                self.create_name.clear();
196 +
                self.create_content.clear();
197 +
            }
198 +
            Err(e) => {
199 +
                self.status_message = Some((e.to_string(), Instant::now()));
129 200
            }
130 201
        }
202 +
    }
203 +
204 +
    fn cancel_create(&mut self) {
205 +
        self.create_name.clear();
206 +
        self.create_content.clear();
207 +
        self.focus = Focus::List;
131 208
    }
132 209
133 210
    fn clear_expired_status(&mut self) {
175 252
}
176 253
177 254
fn main() -> Result<(), Box<dyn std::error::Error>> {
178 -
    let db = db::init_db();
179 -
    let snippets = db::get_all_snippets(&db);
255 +
    let cli = Cli::parse();
180 256
181 -
    ratatui::run(|terminal| run_app(terminal, App::new(snippets), &db))
257 +
    let (backend, is_remote) = match cli.remote {
258 +
        Some(url) => (Backend::remote(url, cli.api_key), true),
259 +
        None => (Backend::local(), false),
260 +
    };
261 +
262 +
    let snippets = match backend.list_snippets() {
263 +
        Ok(s) => s,
264 +
        Err(e) => {
265 +
            eprintln!("Failed to load snippets: {}", e);
266 +
            Vec::new()
267 +
        }
268 +
    };
269 +
270 +
    ratatui::run(|terminal| run_app(terminal, App::new(snippets, is_remote), &backend))
182 271
}
183 272
184 273
fn run_app(
185 274
    terminal: &mut DefaultTerminal,
186 275
    mut app: App,
187 -
    db: &sipp_rust::db::Db,
276 +
    backend: &Backend,
188 277
) -> Result<(), Box<dyn std::error::Error>> {
189 278
    while !app.should_quit {
190 279
        app.clear_expired_status();
195 284
            .unwrap_or(0);
196 285
197 286
        terminal.draw(|frame| {
198 -
            let outer = Layout::vertical([
199 -
                Constraint::Min(1),
200 -
                Constraint::Length(1),
201 -
            ])
202 -
            .split(frame.area());
287 +
            let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)])
288 +
                .split(frame.area());
203 289
204 290
            let chunks = Layout::horizontal([
205 291
                Constraint::Percentage(30),
215 301
216 302
            let list_border_style = match app.focus {
217 303
                Focus::List => Style::default().fg(Color::Yellow),
218 -
                Focus::Content => Style::default().fg(Color::DarkGray),
304 +
                _ => Style::default().fg(Color::DarkGray),
219 305
            };
220 306
            let content_border_style = match app.focus {
221 307
                Focus::Content => Style::default().fg(Color::Yellow),
222 -
                Focus::List => Style::default().fg(Color::DarkGray),
308 +
                _ => Style::default().fg(Color::DarkGray),
223 309
            };
224 310
225 311
            let list = List::new(items)
238 324
239 325
            frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
240 326
241 -
            let highlighted = match app.selected_snippet() {
242 -
                Some(s) => app.highlight_content(&s.name, &s.content),
243 -
                None => Text::raw(""),
244 -
            };
245 -
246 -
            let paragraph = Paragraph::new(highlighted)
247 -
                .block(
248 -
                    Block::default()
249 -
                        .title(" Content ")
327 +
            // Right pane: either create form or snippet content
328 +
            match app.focus {
329 +
                Focus::CreateName | Focus::CreateContent => {
330 +
                    let create_block = Block::default()
331 +
                        .title(" New Snippet ")
250 332
                        .borders(Borders::ALL)
251 -
                        .border_style(content_border_style),
252 -
                )
253 -
                .scroll((app.content_scroll, 0));
333 +
                        .border_style(Style::default().fg(Color::Yellow));
254 334
255 -
            frame.render_widget(paragraph, chunks[1]);
335 +
                    let inner = create_block.inner(chunks[1]);
336 +
                    frame.render_widget(create_block, chunks[1]);
337 +
338 +
                    let form_layout = Layout::vertical([
339 +
                        Constraint::Length(3),
340 +
                        Constraint::Min(1),
341 +
                        Constraint::Length(1),
342 +
                    ])
343 +
                    .split(inner);
344 +
345 +
                    let name_style = match app.focus {
346 +
                        Focus::CreateName => Style::default().fg(Color::Yellow),
347 +
                        _ => Style::default().fg(Color::DarkGray),
348 +
                    };
349 +
                    let name_input = Paragraph::new(app.create_name.as_str()).block(
350 +
                        Block::default()
351 +
                            .title(" Name ")
352 +
                            .borders(Borders::ALL)
353 +
                            .border_style(name_style),
354 +
                    );
355 +
                    frame.render_widget(name_input, form_layout[0]);
356 +
357 +
                    let content_style = match app.focus {
358 +
                        Focus::CreateContent => Style::default().fg(Color::Yellow),
359 +
                        _ => Style::default().fg(Color::DarkGray),
360 +
                    };
361 +
                    let content_input = Paragraph::new(app.create_content.as_str()).block(
362 +
                        Block::default()
363 +
                            .title(" Content ")
364 +
                            .borders(Borders::ALL)
365 +
                            .border_style(content_style),
366 +
                    );
367 +
                    frame.render_widget(content_input, form_layout[1]);
368 +
369 +
                    let hint = Paragraph::new(Line::from(vec![
370 +
                        Span::styled("Enter", Style::default().fg(Color::Yellow)),
371 +
                        Span::raw(match app.focus {
372 +
                            Focus::CreateName => " next field  ",
373 +
                            _ => " newline  ",
374 +
                        }),
375 +
                        Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
376 +
                        Span::raw(" save  "),
377 +
                        Span::styled("Esc", Style::default().fg(Color::Yellow)),
378 +
                        Span::raw(" cancel"),
379 +
                    ]));
380 +
                    frame.render_widget(hint, form_layout[2]);
381 +
                }
382 +
                _ => {
383 +
                    let highlighted = match app.selected_snippet() {
384 +
                        Some(s) => app.highlight_content(&s.name, &s.content),
385 +
                        None => Text::raw(""),
386 +
                    };
387 +
388 +
                    let paragraph = Paragraph::new(highlighted)
389 +
                        .block(
390 +
                            Block::default()
391 +
                                .title(" Content ")
392 +
                                .borders(Borders::ALL)
393 +
                                .border_style(content_border_style),
394 +
                        )
395 +
                        .scroll((app.content_scroll, 0));
396 +
397 +
                    frame.render_widget(paragraph, chunks[1]);
398 +
                }
399 +
            }
256 400
257 401
            if let Some((msg, _)) = &app.status_message {
258 402
                let status = Paragraph::new(Text::raw(msg.as_str()))
262 406
263 407
            if app.show_help {
264 408
                let area = frame.area();
265 -
                let popup_width = 40u16.min(area.width.saturating_sub(4));
266 -
                let popup_height = 14u16.min(area.height.saturating_sub(4));
409 +
                let popup_width = 44u16.min(area.width.saturating_sub(4));
410 +
                let popup_height = 18u16.min(area.height.saturating_sub(4));
267 411
                let popup_area = ratatui::layout::Rect {
268 412
                    x: (area.width.saturating_sub(popup_width)) / 2,
269 413
                    y: (area.height.saturating_sub(popup_height)) / 2,
271 415
                    height: popup_height,
272 416
                };
273 417
274 -
                let help_text = Text::from(vec![
418 +
                let mut help_lines = vec![
275 419
                    Line::from(""),
276 420
                    Line::from(vec![
277 -
                        Span::styled("  j/↓  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
421 +
                        Span::styled(
422 +
                            "  j/↓  ",
423 +
                            Style::default()
424 +
                                .fg(Color::Yellow)
425 +
                                .add_modifier(Modifier::BOLD),
426 +
                        ),
278 427
                        Span::raw("Move down / Scroll down"),
279 428
                    ]),
280 429
                    Line::from(vec![
281 -
                        Span::styled("  k/↑  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
430 +
                        Span::styled(
431 +
                            "  k/↑  ",
432 +
                            Style::default()
433 +
                                .fg(Color::Yellow)
434 +
                                .add_modifier(Modifier::BOLD),
435 +
                        ),
282 436
                        Span::raw("Move up / Scroll up"),
283 437
                    ]),
284 438
                    Line::from(vec![
285 -
                        Span::styled("  Enter", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
439 +
                        Span::styled(
440 +
                            "  Enter",
441 +
                            Style::default()
442 +
                                .fg(Color::Yellow)
443 +
                                .add_modifier(Modifier::BOLD),
444 +
                        ),
286 445
                        Span::raw("  Focus content pane"),
287 446
                    ]),
288 447
                    Line::from(vec![
289 -
                        Span::styled("  Esc  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
448 +
                        Span::styled(
449 +
                            "  Esc  ",
450 +
                            Style::default()
451 +
                                .fg(Color::Yellow)
452 +
                                .add_modifier(Modifier::BOLD),
453 +
                        ),
290 454
                        Span::raw("Back / Quit"),
291 455
                    ]),
292 456
                    Line::from(vec![
293 -
                        Span::styled("  y    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
457 +
                        Span::styled(
458 +
                            "  y    ",
459 +
                            Style::default()
460 +
                                .fg(Color::Yellow)
461 +
                                .add_modifier(Modifier::BOLD),
462 +
                        ),
294 463
                        Span::raw("Copy snippet"),
295 464
                    ]),
296 465
                    Line::from(vec![
297 -
                        Span::styled("  q    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
466 +
                        Span::styled(
467 +
                            "  d    ",
468 +
                            Style::default()
469 +
                                .fg(Color::Yellow)
470 +
                                .add_modifier(Modifier::BOLD),
471 +
                        ),
472 +
                        Span::raw("Delete snippet"),
473 +
                    ]),
474 +
                    Line::from(vec![
475 +
                        Span::styled(
476 +
                            "  c    ",
477 +
                            Style::default()
478 +
                                .fg(Color::Yellow)
479 +
                                .add_modifier(Modifier::BOLD),
480 +
                        ),
481 +
                        Span::raw("Create snippet"),
482 +
                    ]),
483 +
                ];
484 +
485 +
                if app.is_remote {
486 +
                    help_lines.push(Line::from(vec![
487 +
                        Span::styled(
488 +
                            "  r    ",
489 +
                            Style::default()
490 +
                                .fg(Color::Yellow)
491 +
                                .add_modifier(Modifier::BOLD),
492 +
                        ),
493 +
                        Span::raw("Refresh snippets"),
494 +
                    ]));
495 +
                }
496 +
497 +
                help_lines.extend([
498 +
                    Line::from(vec![
499 +
                        Span::styled(
500 +
                            "  q    ",
501 +
                            Style::default()
502 +
                                .fg(Color::Yellow)
503 +
                                .add_modifier(Modifier::BOLD),
504 +
                        ),
298 505
                        Span::raw("Quit"),
299 506
                    ]),
300 507
                    Line::from(vec![
301 -
                        Span::styled("  ?    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
508 +
                        Span::styled(
509 +
                            "  ?    ",
510 +
                            Style::default()
511 +
                                .fg(Color::Yellow)
512 +
                                .add_modifier(Modifier::BOLD),
513 +
                        ),
302 514
                        Span::raw("Toggle this help"),
303 515
                    ]),
304 516
                    Line::from(""),
307 519
                        Style::default().fg(Color::DarkGray),
308 520
                    )),
309 521
                ]);
522 +
523 +
                let help_text = Text::from(help_lines);
310 524
311 525
                Clear.render(popup_area, frame.buffer_mut());
312 526
                let help = Paragraph::new(help_text).block(
330 544
                            KeyCode::Char('j') | KeyCode::Down => app.move_down(),
331 545
                            KeyCode::Char('k') | KeyCode::Up => app.move_up(),
332 546
                            KeyCode::Char('y') => app.copy_selected(),
333 -
                            KeyCode::Char('d') => app.delete_selected(db),
547 +
                            KeyCode::Char('d') => app.delete_selected(backend),
548 +
                            KeyCode::Char('c') => app.start_create(),
549 +
                            KeyCode::Char('r') if app.is_remote => app.refresh(backend),
334 550
                            KeyCode::Char('?') => app.show_help = true,
335 551
                            KeyCode::Enter => {
336 552
                                if app.selected_snippet().is_some() {
351 567
                            KeyCode::Char('?') => app.show_help = true,
352 568
                            _ => {}
353 569
                        },
570 +
                        Focus::CreateName => {
571 +
                            if key.modifiers.contains(KeyModifiers::CONTROL)
572 +
                                && key.code == KeyCode::Char('s')
573 +
                            {
574 +
                                app.save_create(backend);
575 +
                            } else {
576 +
                                match key.code {
577 +
                                    KeyCode::Esc => app.cancel_create(),
578 +
                                    KeyCode::Enter => app.focus = Focus::CreateContent,
579 +
                                    KeyCode::Backspace => {
580 +
                                        app.create_name.pop();
581 +
                                    }
582 +
                                    KeyCode::Char(c) => app.create_name.push(c),
583 +
                                    _ => {}
584 +
                                }
585 +
                            }
586 +
                        }
587 +
                        Focus::CreateContent => {
588 +
                            if key.modifiers.contains(KeyModifiers::CONTROL)
589 +
                                && key.code == KeyCode::Char('s')
590 +
                            {
591 +
                                app.save_create(backend);
592 +
                            } else {
593 +
                                match key.code {
594 +
                                    KeyCode::Esc => app.cancel_create(),
595 +
                                    KeyCode::Enter => app.create_content.push('\n'),
596 +
                                    KeyCode::Backspace => {
597 +
                                        app.create_content.pop();
598 +
                                    }
599 +
                                    KeyCode::Char(c) => app.create_content.push(c),
600 +
                                    _ => {}
601 +
                                }
602 +
                            }
603 +
                        }
354 604
                    }
355 605
                }
356 606
            }
src/db.rs +2 −1
1 1
use rand::RngExt;
2 2
use rusqlite::{Connection, params};
3 +
use serde::{Deserialize, Serialize};
3 4
use std::sync::{Arc, Mutex};
4 5
5 6
pub type Db = Arc<Mutex<Connection>>;
6 7
8 +
#[derive(Serialize, Deserialize)]
7 9
pub struct Snippet {
8 -
    #[allow(dead_code)]
9 10
    pub id: i64,
10 11
    pub short_id: String,
11 12
    pub content: String,
src/lib.rs +1 −0
1 +
pub mod backend;
1 2
pub mod db;
2 3
pub mod highlight;
src/main.rs +66 −3
1 1
use askama::Template;
2 2
use askama_web::WebTemplate;
3 3
use axum::{
4 -
    Router,
4 +
    Json, Router,
5 5
    extract::{Form, Path, State},
6 -
    http::StatusCode,
6 +
    http::{HeaderMap, StatusCode},
7 7
    response::{Html, IntoResponse, Redirect},
8 8
    routing::{get, post},
9 9
};
10 10
use serde::Deserialize;
11 -
use sipp_rust::db::{self, Db};
11 +
use sipp_rust::db::{self, Db, Snippet};
12 12
use sipp_rust::highlight::Highlighter;
13 13
use std::sync::Arc;
14 14
use tower_http::services::ServeDir;
17 17
struct AppState {
18 18
    db: Db,
19 19
    highlighter: Arc<Highlighter>,
20 +
    api_key: Option<String>,
20 21
}
21 22
22 23
#[derive(Template)]
77 78
    Redirect::to(&format!("/s/{}", snippet.short_id))
78 79
}
79 80
81 +
fn check_api_key(state: &AppState, headers: &HeaderMap) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
82 +
    let server_key = match &state.api_key {
83 +
        Some(k) => k,
84 +
        None => return Err((StatusCode::FORBIDDEN, Json(serde_json::json!({"error": "No API key configured on server"})))),
85 +
    };
86 +
    let provided = headers
87 +
        .get("x-api-key")
88 +
        .and_then(|v| v.to_str().ok());
89 +
    match provided {
90 +
        Some(k) if k == server_key => Ok(()),
91 +
        _ => Err((StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "Invalid or missing API key"})))),
92 +
    }
93 +
}
94 +
95 +
async fn api_list_snippets(
96 +
    State(state): State<AppState>,
97 +
    headers: HeaderMap,
98 +
) -> Result<Json<Vec<Snippet>>, (StatusCode, Json<serde_json::Value>)> {
99 +
    check_api_key(&state, &headers)?;
100 +
    Ok(Json(db::get_all_snippets(&state.db)))
101 +
}
102 +
103 +
async fn api_get_snippet(
104 +
    State(state): State<AppState>,
105 +
    Path(short_id): Path<String>,
106 +
) -> Result<Json<Snippet>, (StatusCode, Json<serde_json::Value>)> {
107 +
    match db::get_snippet_by_short_id(&state.db, &short_id) {
108 +
        Some(snippet) => Ok(Json(snippet)),
109 +
        None => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"})))),
110 +
    }
111 +
}
112 +
113 +
#[derive(Deserialize)]
114 +
struct ApiCreateSnippet {
115 +
    name: String,
116 +
    content: String,
117 +
}
118 +
119 +
async fn api_create_snippet(
120 +
    State(state): State<AppState>,
121 +
    Json(body): Json<ApiCreateSnippet>,
122 +
) -> (StatusCode, Json<Snippet>) {
123 +
    let snippet = db::create_snippet(&state.db, &body.name, &body.content);
124 +
    (StatusCode::CREATED, Json(snippet))
125 +
}
126 +
127 +
async fn api_delete_snippet(
128 +
    State(state): State<AppState>,
129 +
    headers: HeaderMap,
130 +
    Path(short_id): Path<String>,
131 +
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
132 +
    check_api_key(&state, &headers)?;
133 +
    if db::delete_snippet_by_short_id(&state.db, &short_id) {
134 +
        Ok(Json(serde_json::json!({"deleted": true})))
135 +
    } else {
136 +
        Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"}))))
137 +
    }
138 +
}
139 +
80 140
#[tokio::main]
81 141
async fn main() {
82 142
    let state = AppState {
83 143
        db: db::init_db(),
84 144
        highlighter: Arc::new(Highlighter::new()),
145 +
        api_key: std::env::var("SIPP_API_KEY").ok(),
85 146
    };
86 147
87 148
    let app = Router::new()
89 150
        .route("/about", get(about))
90 151
        .route("/s/{short_id}", get(view_snippet))
91 152
        .route("/snippets", post(create_snippet))
153 +
        .route("/api/snippets", get(api_list_snippets).post(api_create_snippet))
154 +
        .route("/api/snippets/{short_id}", get(api_get_snippet).delete(api_delete_snippet))
92 155
        .nest_service("/assets", ServeDir::new("assets"))
93 156
        .nest_service("/static", ServeDir::new("static"))
94 157
        .with_state(state);