chore: merged tui and server into single binary c585efcc
Steve · 2026-02-19 09:35 5 file(s) · +327 −243
Cargo.toml +2 −5
4 4
edition = "2024"
5 5
6 6
[[bin]]
7 -
name = "sipp-server"
8 -
path = "src/main.rs"
9 -
10 -
[[bin]]
11 7
name = "sipp"
12 -
path = "src/bin/tui.rs"
8 +
path = "src/main.rs"
13 9
14 10
[dependencies]
15 11
axum = "0.8.8"
30 26
toml = "0.8"
31 27
rpassword = "5"
32 28
open = "5.3.3"
29 +
rust-embed = "8"
src/bin/tui.rs → src/tui.rs +51 −85
1 1
use arboard::Clipboard;
2 -
use clap::{Parser, Subcommand};
3 2
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
4 3
use ratatui::{
5 4
    DefaultTerminal,
8 7
    text::{Line, Span, Text},
9 8
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Widget},
10 9
};
11 -
use sipp_rust::backend::Backend;
12 -
use sipp_rust::config;
13 -
use sipp_rust::db::Snippet;
10 +
use crate::backend::Backend;
11 +
use crate::config;
12 +
use crate::db::Snippet;
14 13
use std::io::Cursor;
15 14
use std::path::PathBuf;
16 15
use std::time::{Duration, Instant};
19 18
use syntect::parsing::SyntaxSet;
20 19
use syntect::util::LinesWithEndings;
21 20
22 -
#[derive(Parser)]
23 -
#[command(name = "sipp-tui", about = "TUI client for sipp snippets")]
24 -
struct Cli {
25 -
    /// Remote server URL (e.g. http://localhost:3000)
26 -
    #[arg(short, long, env = "SIPP_REMOTE_URL")]
27 -
    remote: Option<String>,
28 -
29 -
    /// API key for authenticated operations
30 -
    #[arg(short = 'k', long, env = "SIPP_API_KEY")]
31 -
    api_key: Option<String>,
32 -
33 -
    /// File path to create a snippet from (uses filename as name, file contents as content)
34 -
    #[arg(value_name = "FILE")]
35 -
    file: Option<PathBuf>,
36 -
37 -
    #[command(subcommand)]
38 -
    command: Option<Commands>,
39 -
}
40 -
41 -
#[derive(Subcommand)]
42 -
enum Commands {
43 -
    /// Save remote URL and API key to config file
44 -
    Auth,
45 -
}
46 -
47 21
enum Focus {
48 22
    List,
49 23
    Content,
74 48
            list_state.select(Some(0));
75 49
        }
76 50
        let syntax_set = SyntaxSet::load_defaults_newlines();
77 -
        let theme_data = include_bytes!("../ansi.tmTheme");
51 +
        let theme_data = include_bytes!("ansi.tmTheme");
78 52
        let theme =
79 53
            syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..]))
80 54
                .expect("failed to load base16 theme");
308 282
    }
309 283
}
310 284
311 -
fn run_auth() -> Result<(), Box<dyn std::error::Error>> {
285 +
fn resolve_backend(remote: Option<String>, api_key: Option<String>) -> (Backend, bool, Option<String>) {
286 +
    if let Some(url) = remote {
287 +
        return (
288 +
            Backend::remote(url.clone(), api_key),
289 +
            true,
290 +
            Some(url),
291 +
        );
292 +
    }
293 +
294 +
    if !std::path::Path::new("sipp.sqlite").exists() {
295 +
        let cfg = config::load_config();
296 +
        if let Some(url) = cfg.remote_url {
297 +
            return (Backend::remote(url.clone(), cfg.api_key), true, Some(url));
298 +
        }
299 +
    }
300 +
301 +
    (Backend::local(), false, None)
302 +
}
303 +
304 +
pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> {
312 305
    use std::io::{self, Write};
313 306
314 307
    print!("Remote URL: ");
340 333
    Ok(())
341 334
}
342 335
343 -
fn resolve_backend(cli: &Cli) -> (Backend, bool, Option<String>) {
344 -
    // 1. CLI flags / env vars take highest priority
345 -
    if let Some(url) = &cli.remote {
346 -
        return (
347 -
            Backend::remote(url.clone(), cli.api_key.clone()),
348 -
            true,
349 -
            Some(url.clone()),
350 -
        );
351 -
    }
352 -
353 -
    // 2. If no local DB exists, try config file
354 -
    if !std::path::Path::new("sipp.sqlite").exists() {
355 -
        let cfg = config::load_config();
356 -
        if let Some(url) = cfg.remote_url {
357 -
            return (Backend::remote(url.clone(), cfg.api_key), true, Some(url));
358 -
        }
359 -
    }
360 -
361 -
    // 3. Fallback to local DB (creates it if needed)
362 -
    (Backend::local(), false, None)
363 -
}
364 -
365 -
fn main() -> Result<(), Box<dyn std::error::Error>> {
366 -
    let cli = Cli::parse();
367 -
368 -
    if let Some(Commands::Auth) = &cli.command {
369 -
        return run_auth();
370 -
    }
371 -
372 -
    let (backend, is_remote, remote_url) = resolve_backend(&cli);
373 -
374 -
    if let Some(file_path) = &cli.file {
375 -
        let name = file_path
376 -
            .file_name()
377 -
            .ok_or("Invalid file path")?
378 -
            .to_string_lossy()
379 -
            .to_string();
380 -
        let content = std::fs::read_to_string(file_path)
381 -
            .map_err(|e| format!("Failed to read file: {}", e))?;
382 -
        let snippet = backend
383 -
            .create_snippet(&name, &content)
384 -
            .map_err(|e| format!("{}", e))?;
385 -
        let link = match &remote_url {
386 -
            Some(url) => format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id),
387 -
            None => snippet.short_id.clone(),
388 -
        };
389 -
        println!("{}", link);
390 -
        if let Ok(mut clipboard) = Clipboard::new() {
391 -
            let _ = clipboard.set_text(&link);
392 -
            println!("\u{2714} Copied to clipboard!");
393 -
        }
394 -
        return Ok(());
395 -
    }
336 +
pub fn run_interactive(remote: Option<String>, api_key: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
337 +
    let (backend, is_remote, remote_url) = resolve_backend(remote, api_key);
396 338
397 339
    let snippets = match backend.list_snippets() {
398 340
        Ok(s) => s,
403 345
    };
404 346
405 347
    ratatui::run(|terminal| run_app(terminal, App::new(snippets, is_remote, remote_url), &backend))
348 +
}
349 +
350 +
pub fn run_file_upload(remote: Option<String>, api_key: Option<String>, file: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
351 +
    let (backend, _, remote_url) = resolve_backend(remote, api_key);
352 +
353 +
    let name = file
354 +
        .file_name()
355 +
        .ok_or("Invalid file path")?
356 +
        .to_string_lossy()
357 +
        .to_string();
358 +
    let content = std::fs::read_to_string(&file)
359 +
        .map_err(|e| format!("Failed to read file: {}", e))?;
360 +
    let snippet = backend
361 +
        .create_snippet(&name, &content)
362 +
        .map_err(|e| format!("{}", e))?;
363 +
    let link = match &remote_url {
364 +
        Some(url) => format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id),
365 +
        None => snippet.short_id.clone(),
366 +
    };
367 +
    println!("{}", link);
368 +
    if let Ok(mut clipboard) = Clipboard::new() {
369 +
        let _ = clipboard.set_text(&link);
370 +
        println!("\u{2714} Copied to clipboard!");
371 +
    }
372 +
    Ok(())
406 373
}
407 374
408 375
fn run_app(
459 426
460 427
            frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
461 428
462 -
            // Right pane: either create form or snippet content
463 429
            match app.focus {
464 430
                Focus::CreateName | Focus::CreateContent => {
465 431
                    let create_block = Block::default()
src/lib.rs +2 −0
2 2
pub mod config;
3 3
pub mod db;
4 4
pub mod highlight;
5 +
pub mod server;
6 +
pub mod tui;
src/main.rs +58 −153
1 -
use askama::Template;
2 -
use askama_web::WebTemplate;
3 -
use axum::{
4 -
    Json, Router,
5 -
    extract::{Form, Path, State},
6 -
    http::{HeaderMap, StatusCode},
7 -
    response::{Html, IntoResponse, Redirect},
8 -
    routing::{get, post},
9 -
};
10 -
use serde::Deserialize;
11 -
use sipp_rust::db::{self, Db, Snippet};
12 -
use sipp_rust::highlight::Highlighter;
13 -
use std::sync::Arc;
14 -
use tower_http::services::ServeDir;
1 +
use clap::{Parser, Subcommand};
2 +
use std::path::PathBuf;
3 +
4 +
#[derive(Parser)]
5 +
#[command(name = "sipp", about = "Snippet manager — TUI, server, and CLI")]
6 +
struct Cli {
7 +
    /// Remote server URL (e.g. http://localhost:3000)
8 +
    #[arg(short, long, env = "SIPP_REMOTE_URL")]
9 +
    remote: Option<String>,
15 10
16 -
#[derive(Clone)]
17 -
struct AppState {
18 -
    db: Db,
19 -
    highlighter: Arc<Highlighter>,
11 +
    /// API key for authenticated operations
12 +
    #[arg(short = 'k', long, env = "SIPP_API_KEY")]
20 13
    api_key: Option<String>,
21 -
}
22 14
23 -
#[derive(Template)]
24 -
#[template(path = "index.html")]
25 -
struct IndexTemplate;
15 +
    /// File path to create a snippet from
16 +
    #[arg(value_name = "FILE")]
17 +
    file: Option<PathBuf>,
26 18
27 -
#[derive(Template)]
28 -
#[template(path = "snippet.html")]
29 -
struct SnippetTemplate {
30 -
    name: String,
31 -
    content: String,
32 -
    highlighted_content: String,
19 +
    #[command(subcommand)]
20 +
    command: Option<Commands>,
33 21
}
34 22
35 -
#[derive(Template)]
36 -
#[template(path = "about.html")]
37 -
struct AboutTemplate;
23 +
#[derive(Subcommand)]
24 +
enum Commands {
25 +
    /// Start the web server
26 +
    Server {
27 +
        /// Port to listen on
28 +
        #[arg(short, long, default_value_t = 3000)]
29 +
        port: u16,
38 30
39 -
#[derive(Deserialize)]
40 -
struct CreateSnippetForm {
41 -
    name: String,
42 -
    content: String,
43 -
}
31 +
        /// Host to bind to
32 +
        #[arg(long, default_value = "localhost")]
33 +
        host: String,
34 +
    },
35 +
    /// Launch the interactive TUI
36 +
    Tui {
37 +
        /// Remote server URL (e.g. http://localhost:3000)
38 +
        #[arg(short, long, env = "SIPP_REMOTE_URL")]
39 +
        remote: Option<String>,
44 40
45 -
async fn index() -> WebTemplate<IndexTemplate> {
46 -
    WebTemplate(IndexTemplate)
41 +
        /// API key for authenticated operations
42 +
        #[arg(short = 'k', long, env = "SIPP_API_KEY")]
43 +
        api_key: Option<String>,
44 +
    },
45 +
    /// Save remote URL and API key to config file
46 +
    Auth,
47 47
}
48 48
49 -
async fn about() -> WebTemplate<AboutTemplate> {
50 -
    WebTemplate(AboutTemplate)
51 -
}
49 +
fn main() -> Result<(), Box<dyn std::error::Error>> {
50 +
    let cli = Cli::parse();
52 51
53 -
async fn view_snippet(
54 -
    State(state): State<AppState>,
55 -
    Path(short_id): Path<String>,
56 -
) -> Result<WebTemplate<SnippetTemplate>, (StatusCode, Html<String>)> {
57 -
    match db::get_snippet_by_short_id(&state.db, &short_id) {
58 -
        Some(snippet) => {
59 -
            let highlighted_content = state.highlighter.highlight(&snippet.name, &snippet.content);
60 -
            Ok(WebTemplate(SnippetTemplate {
61 -
                name: snippet.name,
62 -
                content: snippet.content,
63 -
                highlighted_content,
64 -
            }))
52 +
    match cli.command {
53 +
        Some(Commands::Server { port, host }) => {
54 +
            let rt = tokio::runtime::Runtime::new()?;
55 +
            rt.block_on(sipp_rust::server::run(host, port));
56 +
        }
57 +
        Some(Commands::Tui { remote, api_key }) => {
58 +
            sipp_rust::tui::run_interactive(remote, api_key)?;
59 +
        }
60 +
        Some(Commands::Auth) => {
61 +
            sipp_rust::tui::run_auth()?;
62 +
        }
63 +
        None => {
64 +
            if let Some(file) = cli.file {
65 +
                sipp_rust::tui::run_file_upload(cli.remote, cli.api_key, file)?;
66 +
            } else {
67 +
                sipp_rust::tui::run_interactive(cli.remote, cli.api_key)?;
68 +
            }
65 69
        }
66 -
        None => Err((
67 -
            StatusCode::NOT_FOUND,
68 -
            Html("<h1>Snippet not found</h1>".to_string()),
69 -
        )),
70 70
    }
71 -
}
72 71
73 -
async fn create_snippet(
74 -
    State(state): State<AppState>,
75 -
    Form(form): Form<CreateSnippetForm>,
76 -
) -> impl IntoResponse {
77 -
    let snippet = db::create_snippet(&state.db, &form.name, &form.content);
78 -
    Redirect::to(&format!("/s/{}", snippet.short_id))
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 -
140 -
#[tokio::main]
141 -
async fn main() {
142 -
    let state = AppState {
143 -
        db: db::init_db(),
144 -
        highlighter: Arc::new(Highlighter::new()),
145 -
        api_key: std::env::var("SIPP_API_KEY").ok(),
146 -
    };
147 -
148 -
    let app = Router::new()
149 -
        .route("/", get(index))
150 -
        .route("/about", get(about))
151 -
        .route("/s/{short_id}", get(view_snippet))
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))
155 -
        .nest_service("/assets", ServeDir::new("assets"))
156 -
        .nest_service("/static", ServeDir::new("static"))
157 -
        .with_state(state);
158 -
159 -
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
160 -
        .await
161 -
        .expect("Failed to bind to port 3000");
162 -
163 -
    println!("Server running at http://localhost:3000");
164 -
165 -
    axum::serve(listener, app)
166 -
        .await
167 -
        .expect("Failed to start server");
72 +
    Ok(())
168 73
}
src/server.rs (added) +214 −0
1 +
use askama::Template;
2 +
use askama_web::WebTemplate;
3 +
use axum::{
4 +
    Json, Router,
5 +
    extract::{Form, Path, State},
6 +
    http::{HeaderMap, StatusCode, header},
7 +
    response::{Html, IntoResponse, Redirect, Response},
8 +
    routing::{get, post},
9 +
};
10 +
use rust_embed::Embed;
11 +
use serde::Deserialize;
12 +
use crate::db::{self, Db, Snippet};
13 +
use crate::highlight::Highlighter;
14 +
use std::sync::Arc;
15 +
16 +
#[derive(Embed)]
17 +
#[folder = "assets/"]
18 +
struct Assets;
19 +
20 +
#[derive(Embed)]
21 +
#[folder = "static/"]
22 +
struct Static;
23 +
24 +
#[derive(Clone)]
25 +
struct AppState {
26 +
    db: Db,
27 +
    highlighter: Arc<Highlighter>,
28 +
    api_key: Option<String>,
29 +
}
30 +
31 +
#[derive(Template)]
32 +
#[template(path = "index.html")]
33 +
struct IndexTemplate;
34 +
35 +
#[derive(Template)]
36 +
#[template(path = "snippet.html")]
37 +
struct SnippetTemplate {
38 +
    name: String,
39 +
    content: String,
40 +
    highlighted_content: String,
41 +
}
42 +
43 +
#[derive(Template)]
44 +
#[template(path = "about.html")]
45 +
struct AboutTemplate;
46 +
47 +
#[derive(Deserialize)]
48 +
struct CreateSnippetForm {
49 +
    name: String,
50 +
    content: String,
51 +
}
52 +
53 +
async fn index() -> WebTemplate<IndexTemplate> {
54 +
    WebTemplate(IndexTemplate)
55 +
}
56 +
57 +
async fn about() -> WebTemplate<AboutTemplate> {
58 +
    WebTemplate(AboutTemplate)
59 +
}
60 +
61 +
async fn view_snippet(
62 +
    State(state): State<AppState>,
63 +
    Path(short_id): Path<String>,
64 +
) -> Result<WebTemplate<SnippetTemplate>, (StatusCode, Html<String>)> {
65 +
    match db::get_snippet_by_short_id(&state.db, &short_id) {
66 +
        Some(snippet) => {
67 +
            let highlighted_content = state.highlighter.highlight(&snippet.name, &snippet.content);
68 +
            Ok(WebTemplate(SnippetTemplate {
69 +
                name: snippet.name,
70 +
                content: snippet.content,
71 +
                highlighted_content,
72 +
            }))
73 +
        }
74 +
        None => Err((
75 +
            StatusCode::NOT_FOUND,
76 +
            Html("<h1>Snippet not found</h1>".to_string()),
77 +
        )),
78 +
    }
79 +
}
80 +
81 +
async fn create_snippet(
82 +
    State(state): State<AppState>,
83 +
    Form(form): Form<CreateSnippetForm>,
84 +
) -> impl IntoResponse {
85 +
    let snippet = db::create_snippet(&state.db, &form.name, &form.content);
86 +
    Redirect::to(&format!("/s/{}", snippet.short_id))
87 +
}
88 +
89 +
fn check_api_key(state: &AppState, headers: &HeaderMap) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
90 +
    let server_key = match &state.api_key {
91 +
        Some(k) => k,
92 +
        None => return Err((StatusCode::FORBIDDEN, Json(serde_json::json!({"error": "No API key configured on server"})))),
93 +
    };
94 +
    let provided = headers
95 +
        .get("x-api-key")
96 +
        .and_then(|v| v.to_str().ok());
97 +
    match provided {
98 +
        Some(k) if k == server_key => Ok(()),
99 +
        _ => Err((StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "Invalid or missing API key"})))),
100 +
    }
101 +
}
102 +
103 +
async fn api_list_snippets(
104 +
    State(state): State<AppState>,
105 +
    headers: HeaderMap,
106 +
) -> Result<Json<Vec<Snippet>>, (StatusCode, Json<serde_json::Value>)> {
107 +
    check_api_key(&state, &headers)?;
108 +
    Ok(Json(db::get_all_snippets(&state.db)))
109 +
}
110 +
111 +
async fn api_get_snippet(
112 +
    State(state): State<AppState>,
113 +
    Path(short_id): Path<String>,
114 +
) -> Result<Json<Snippet>, (StatusCode, Json<serde_json::Value>)> {
115 +
    match db::get_snippet_by_short_id(&state.db, &short_id) {
116 +
        Some(snippet) => Ok(Json(snippet)),
117 +
        None => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"})))),
118 +
    }
119 +
}
120 +
121 +
#[derive(Deserialize)]
122 +
struct ApiCreateSnippet {
123 +
    name: String,
124 +
    content: String,
125 +
}
126 +
127 +
async fn api_create_snippet(
128 +
    State(state): State<AppState>,
129 +
    Json(body): Json<ApiCreateSnippet>,
130 +
) -> (StatusCode, Json<Snippet>) {
131 +
    let snippet = db::create_snippet(&state.db, &body.name, &body.content);
132 +
    (StatusCode::CREATED, Json(snippet))
133 +
}
134 +
135 +
async fn api_delete_snippet(
136 +
    State(state): State<AppState>,
137 +
    headers: HeaderMap,
138 +
    Path(short_id): Path<String>,
139 +
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
140 +
    check_api_key(&state, &headers)?;
141 +
    if db::delete_snippet_by_short_id(&state.db, &short_id) {
142 +
        Ok(Json(serde_json::json!({"deleted": true})))
143 +
    } else {
144 +
        Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"}))))
145 +
    }
146 +
}
147 +
148 +
fn mime_from_path(path: &str) -> &'static str {
149 +
    match path.rsplit('.').next().unwrap_or("") {
150 +
        "css" => "text/css",
151 +
        "js" => "application/javascript",
152 +
        "html" => "text/html",
153 +
        "png" => "image/png",
154 +
        "ico" => "image/x-icon",
155 +
        "svg" => "image/svg+xml",
156 +
        "woff" => "font/woff",
157 +
        "woff2" => "font/woff2",
158 +
        "ttf" => "font/ttf",
159 +
        "otf" => "font/otf",
160 +
        "json" | "webmanifest" => "application/json",
161 +
        "jpg" | "jpeg" => "image/jpeg",
162 +
        _ => "application/octet-stream",
163 +
    }
164 +
}
165 +
166 +
async fn serve_assets(Path(path): Path<String>) -> Response {
167 +
    match Assets::get(&path) {
168 +
        Some(file) => {
169 +
            let mime = mime_from_path(&path);
170 +
            ([(header::CONTENT_TYPE, mime)], file.data).into_response()
171 +
        }
172 +
        None => StatusCode::NOT_FOUND.into_response(),
173 +
    }
174 +
}
175 +
176 +
async fn serve_static(Path(path): Path<String>) -> Response {
177 +
    match Static::get(&path) {
178 +
        Some(file) => {
179 +
            let mime = mime_from_path(&path);
180 +
            ([(header::CONTENT_TYPE, mime)], file.data).into_response()
181 +
        }
182 +
        None => StatusCode::NOT_FOUND.into_response(),
183 +
    }
184 +
}
185 +
186 +
pub async fn run(host: String, port: u16) {
187 +
    let state = AppState {
188 +
        db: db::init_db(),
189 +
        highlighter: Arc::new(Highlighter::new()),
190 +
        api_key: std::env::var("SIPP_API_KEY").ok(),
191 +
    };
192 +
193 +
    let app = Router::new()
194 +
        .route("/", get(index))
195 +
        .route("/about", get(about))
196 +
        .route("/s/{short_id}", get(view_snippet))
197 +
        .route("/snippets", post(create_snippet))
198 +
        .route("/api/snippets", get(api_list_snippets).post(api_create_snippet))
199 +
        .route("/api/snippets/{short_id}", get(api_get_snippet).delete(api_delete_snippet))
200 +
        .route("/assets/{*path}", get(serve_assets))
201 +
        .route("/static/{*path}", get(serve_static))
202 +
        .with_state(state);
203 +
204 +
    let addr = format!("{}:{}", host, port);
205 +
    let listener = tokio::net::TcpListener::bind(&addr)
206 +
        .await
207 +
        .unwrap_or_else(|_| panic!("Failed to bind to {}", addr));
208 +
209 +
    println!("Server running at http://{}:{}", host, port);
210 +
211 +
    axum::serve(listener, app)
212 +
        .await
213 +
        .expect("Failed to start server");
214 +
}