chore: added max file size and plain text response for curl
508d6d2b
2 file(s) · +72 −8
| 8 | 8 | ||
| 9 | 9 | - Single binary for web server and TUI |
|
| 10 | 10 | - Create snippets and share on the web |
|
| 11 | + | - Raw output for CLI tools — `curl`, `wget`, and `httpie` get plain text automatically |
|
| 11 | 12 | - Interactive TUI with authenticated access for snippet management |
|
| 12 | 13 | - Minimal, fast, and low memory consumption |
|
| 13 | 14 | ||
| 112 | 113 | |---|---| |
|
| 113 | 114 | | `SIPP_API_KEY` | API key for protecting endpoints | |
|
| 114 | 115 | | `SIPP_AUTH_ENDPOINTS` | Comma-separated list of endpoints requiring auth: `api_list`, `api_create`, `api_get`, `api_delete`, `all`, or `none` (defaults to `api_delete,api_list`) | |
|
| 116 | + | | `SIPP_MAX_CONTENT_SIZE` | Maximum snippet content size in bytes (defaults to `512000` / 500 KB) | |
|
| 115 | 117 | | `SIPP_DB_PATH` | Custom path for the SQLite database file (defaults to `sipp.sqlite` in the working directory) | |
|
| 116 | 118 | ||
| 117 | 119 | The server stores snippets in a local `sipp.sqlite` SQLite database. |
|
| 127 | 129 | | `DELETE` | `/api/snippets/{short_id}` | Delete a snippet by ID | |
|
| 128 | 130 | ||
| 129 | 131 | Authenticated endpoints require an `x-api-key` header. |
|
| 132 | + | ||
| 133 | + | #### Raw Output for CLI Tools |
|
| 134 | + | ||
| 135 | + | When you access a snippet URL (`/s/{short_id}`) with `curl`, `wget`, or `httpie`, the server returns the raw content as plain text instead of HTML: |
|
| 136 | + | ||
| 137 | + | ```bash |
|
| 138 | + | curl https://sipp.so/s/abc123 |
|
| 139 | + | ``` |
|
| 130 | 140 | ||
| 131 | 141 | ### TUI |
|
| 132 | 142 | ||
| 28 | 28 | struct ServerConfig { |
|
| 29 | 29 | api_key: Option<String>, |
|
| 30 | 30 | auth_endpoints: HashSet<String>, |
|
| 31 | + | max_content_size: usize, |
|
| 31 | 32 | } |
|
| 32 | 33 | ||
| 33 | 34 | impl ServerConfig { |
|
| 38 | 39 | Ok(val) => val.split(',').map(|s| s.trim().to_lowercase()).collect(), |
|
| 39 | 40 | Err(_) => ["api_delete", "api_list", "api_update"].iter().map(|s| s.to_string()).collect(), |
|
| 40 | 41 | }; |
|
| 41 | - | ServerConfig { api_key, auth_endpoints } |
|
| 42 | + | let max_content_size = std::env::var("SIPP_MAX_CONTENT_SIZE") |
|
| 43 | + | .ok() |
|
| 44 | + | .and_then(|v| v.parse().ok()) |
|
| 45 | + | .unwrap_or(512_000); |
|
| 46 | + | ServerConfig { api_key, auth_endpoints, max_content_size } |
|
| 42 | 47 | } |
|
| 43 | 48 | ||
| 44 | 49 | fn requires_auth(&self, name: &str) -> bool { |
|
| 73 | 78 | ||
| 74 | 79 | async fn index() -> WebTemplate<IndexTemplate> { |
|
| 75 | 80 | WebTemplate(IndexTemplate) |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | fn is_cli_user_agent(headers: &HeaderMap) -> bool { |
|
| 84 | + | headers |
|
| 85 | + | .get(header::USER_AGENT) |
|
| 86 | + | .and_then(|v| v.to_str().ok()) |
|
| 87 | + | .map(|ua| { |
|
| 88 | + | let ua = ua.to_lowercase(); |
|
| 89 | + | ua.starts_with("curl/") || ua.starts_with("wget/") || ua.starts_with("httpie/") |
|
| 90 | + | }) |
|
| 91 | + | .unwrap_or(false) |
|
| 76 | 92 | } |
|
| 77 | 93 | ||
| 78 | 94 | async fn view_snippet( |
|
| 79 | 95 | State(state): State<AppState>, |
|
| 80 | 96 | Path(short_id): Path<String>, |
|
| 81 | - | ) -> Result<WebTemplate<SnippetTemplate>, (StatusCode, Html<String>)> { |
|
| 97 | + | headers: HeaderMap, |
|
| 98 | + | ) -> Result<Response, (StatusCode, Html<String>)> { |
|
| 82 | 99 | match db::get_snippet_by_short_id(&state.db, &short_id) { |
|
| 83 | 100 | Ok(Some(snippet)) => { |
|
| 84 | - | let highlighted_content = state.highlighter.highlight(&snippet.name, &snippet.content); |
|
| 85 | - | Ok(WebTemplate(SnippetTemplate { |
|
| 86 | - | name: snippet.name, |
|
| 87 | - | content: snippet.content, |
|
| 88 | - | highlighted_content, |
|
| 89 | - | })) |
|
| 101 | + | if is_cli_user_agent(&headers) { |
|
| 102 | + | Ok(( |
|
| 103 | + | [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], |
|
| 104 | + | snippet.content, |
|
| 105 | + | ) |
|
| 106 | + | .into_response()) |
|
| 107 | + | } else { |
|
| 108 | + | let highlighted_content = |
|
| 109 | + | state.highlighter.highlight(&snippet.name, &snippet.content); |
|
| 110 | + | Ok(WebTemplate(SnippetTemplate { |
|
| 111 | + | name: snippet.name, |
|
| 112 | + | content: snippet.content, |
|
| 113 | + | highlighted_content, |
|
| 114 | + | }) |
|
| 115 | + | .into_response()) |
|
| 116 | + | } |
|
| 90 | 117 | } |
|
| 91 | 118 | Ok(None) => Err(( |
|
| 92 | 119 | StatusCode::NOT_FOUND, |
|
| 103 | 130 | State(state): State<AppState>, |
|
| 104 | 131 | Form(form): Form<CreateSnippetForm>, |
|
| 105 | 132 | ) -> Result<Redirect, (StatusCode, Html<String>)> { |
|
| 133 | + | if form.content.len() > state.server_config.max_content_size { |
|
| 134 | + | return Err(( |
|
| 135 | + | StatusCode::PAYLOAD_TOO_LARGE, |
|
| 136 | + | Html(format!( |
|
| 137 | + | "<h1>Content too large</h1><p>Maximum size is {} bytes</p>", |
|
| 138 | + | state.server_config.max_content_size |
|
| 139 | + | )), |
|
| 140 | + | )); |
|
| 141 | + | } |
|
| 106 | 142 | match db::create_snippet(&state.db, &form.name, &form.content) { |
|
| 107 | 143 | Ok(snippet) => Ok(Redirect::to(&format!("/s/{}", snippet.short_id))), |
|
| 108 | 144 | Err(_) => Err(( |
|
| 167 | 203 | State(state): State<AppState>, |
|
| 168 | 204 | Json(body): Json<ApiCreateSnippet>, |
|
| 169 | 205 | ) -> Result<(StatusCode, Json<Snippet>), (StatusCode, Json<serde_json::Value>)> { |
|
| 206 | + | if body.content.len() > state.server_config.max_content_size { |
|
| 207 | + | return Err(( |
|
| 208 | + | StatusCode::PAYLOAD_TOO_LARGE, |
|
| 209 | + | Json(serde_json::json!({ |
|
| 210 | + | "error": format!("Content too large. Maximum size is {} bytes", state.server_config.max_content_size) |
|
| 211 | + | })), |
|
| 212 | + | )); |
|
| 213 | + | } |
|
| 170 | 214 | match db::create_snippet(&state.db, &body.name, &body.content) { |
|
| 171 | 215 | Ok(snippet) => Ok((StatusCode::CREATED, Json(snippet))), |
|
| 172 | 216 | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 189 | 233 | Path(short_id): Path<String>, |
|
| 190 | 234 | Json(body): Json<ApiCreateSnippet>, |
|
| 191 | 235 | ) -> Result<Json<Snippet>, (StatusCode, Json<serde_json::Value>)> { |
|
| 236 | + | if body.content.len() > state.server_config.max_content_size { |
|
| 237 | + | return Err(( |
|
| 238 | + | StatusCode::PAYLOAD_TOO_LARGE, |
|
| 239 | + | Json(serde_json::json!({ |
|
| 240 | + | "error": format!("Content too large. Maximum size is {} bytes", state.server_config.max_content_size) |
|
| 241 | + | })), |
|
| 242 | + | )); |
|
| 243 | + | } |
|
| 192 | 244 | match db::update_snippet_by_short_id(&state.db, &short_id, &body.name, &body.content) { |
|
| 193 | 245 | Ok(Some(snippet)) => Ok(Json(snippet)), |
|
| 194 | 246 | Ok(None) => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"})))), |
|
| 311 | 363 | let names: Vec<&str> = server_config.auth_endpoints.iter().map(|s| s.as_str()).collect(); |
|
| 312 | 364 | println!("Auth: enabled for endpoints: {}", names.join(", ")); |
|
| 313 | 365 | } |
|
| 366 | + | ||
| 367 | + | println!("Max content size: {} bytes", server_config.max_content_size); |
|
| 314 | 368 | ||
| 315 | 369 | let state = AppState { |
|
| 316 | 370 | db: db::init_db().expect("Failed to initialize database"), |
|