chore: merged tui and server into single binary
c585efcc
5 file(s) · +327 −243
| 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" |
|
| 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() |
|
| 2 | 2 | pub mod config; |
|
| 3 | 3 | pub mod db; |
|
| 4 | 4 | pub mod highlight; |
|
| 5 | + | pub mod server; |
|
| 6 | + | pub mod tui; |
| 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 | } |
| 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 | + | } |