chore: added max file size and plain text response for curl 508d6d2b
Steve · 2026-02-20 22:11 2 file(s) · +72 −8
README.md +10 −0
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
src/server.rs +62 −8
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"),