chore: initial refactor to use andromeda auth a42eab57
Steve · 2026-04-18 14:56 11 file(s) · +323 −97
Cargo.lock +1 −0
4204 4204
name = "sipp-so"
4205 4205
version = "0.1.6"
4206 4206
dependencies = [
4207 +
 "andromeda-auth",
4207 4208
 "andromeda-db",
4208 4209
 "arboard",
4209 4210
 "askama 0.15.6",
apps/sipp/Cargo.toml +2 −1
28 28
dotenvy = { workspace = true }
29 29
subtle = { workspace = true }
30 30
rusqlite = { workspace = true }
31 -
andromeda-db = { workspace = true }
31 +
andromeda-db = { workspace = true, features = ["session"] }
32 +
andromeda-auth = { workspace = true }
32 33
askama = "0.15.4"
33 34
askama_web = { version = "0.15.1", features = ["axum-0.8"] }
34 35
ratatui = "0.30"
apps/sipp/src/auth.rs (added) +58 −0
1 +
use axum::{
2 +
    extract::FromRequestParts,
3 +
    http::request::Parts,
4 +
    response::{IntoResponse, Redirect, Response},
5 +
};
6 +
7 +
use crate::db;
8 +
use crate::server::AppState;
9 +
10 +
pub use andromeda_auth::{
11 +
    build_session_cookie, clear_session_cookie, generate_session_token, verify_api_key,
12 +
};
13 +
14 +
pub struct AuthSession;
15 +
16 +
impl FromRequestParts<AppState> for AuthSession {
17 +
    type Rejection = Response;
18 +
19 +
    async fn from_request_parts(
20 +
        parts: &mut Parts,
21 +
        state: &AppState,
22 +
    ) -> Result<Self, Self::Rejection> {
23 +
        if let Some(token) = andromeda_auth::extract_session_cookie(&parts.headers) {
24 +
            if is_valid_session(state, &token) {
25 +
                return Ok(AuthSession);
26 +
            }
27 +
        }
28 +
        let path = parts
29 +
            .uri
30 +
            .path_and_query()
31 +
            .map(|pq| pq.as_str())
32 +
            .unwrap_or(parts.uri.path());
33 +
        let login_url = format!("/admin/login?next={}", urlencoding(path));
34 +
        Err(Redirect::to(&login_url).into_response())
35 +
    }
36 +
}
37 +
38 +
pub fn is_valid_session(state: &AppState, token: &str) -> bool {
39 +
    match db::get_session_expiry(&state.db, token) {
40 +
        Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(),
41 +
        _ => false,
42 +
    }
43 +
}
44 +
45 +
pub fn urlencoding(s: &str) -> String {
46 +
    let mut out = String::with_capacity(s.len());
47 +
    for b in s.bytes() {
48 +
        match b {
49 +
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
50 +
                out.push(b as char);
51 +
            }
52 +
            _ => {
53 +
                out.push_str(&format!("%{:02X}", b));
54 +
            }
55 +
        }
56 +
    }
57 +
    out
58 +
}
apps/sipp/src/db.rs +9 −0
3 3
use serde::{Deserialize, Serialize};
4 4
use std::sync::{Arc, Mutex};
5 5
6 +
pub use andromeda_db::session::{
7 +
    delete_session, get_session_expiry, insert_session, prune_expired_sessions,
8 +
};
6 9
pub use andromeda_db::{Db, DbError};
7 10
8 11
#[derive(Serialize, Deserialize)]
44 47
        short_id TEXT NOT NULL UNIQUE,
45 48
        content TEXT NOT NULL,
46 49
        name TEXT NOT NULL
50 +
    );
51 +
52 +
    CREATE TABLE IF NOT EXISTS sessions (
53 +
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
54 +
        token      TEXT NOT NULL UNIQUE,
55 +
        expires_at TEXT NOT NULL
47 56
    );
48 57
";
49 58
apps/sipp/src/lib.rs +1 −0
1 +
pub mod auth;
1 2
pub mod backend;
2 3
pub mod config;
3 4
pub mod db;
apps/sipp/src/server.rs +141 −18
1 1
use askama::Template;
2 2
use askama_web::WebTemplate;
3 -
use subtle::ConstantTimeEq;
4 3
use axum::{
5 4
    Form, Json, Router,
6 -
    extract::{Path, Request, State},
7 -
    http::{HeaderMap, StatusCode, header},
5 +
    extract::{Path, Query, Request, State},
6 +
    http::{HeaderMap, HeaderValue, StatusCode, header},
8 7
    middleware::{self, Next},
9 8
    response::{Html, IntoResponse, Redirect, Response},
10 9
    routing::{delete, get, post, put},
11 10
};
12 11
use rust_embed::Embed;
13 12
use serde::Deserialize;
13 +
use crate::auth;
14 14
use crate::db::{self, Db, Snippet};
15 15
use crate::highlight::Highlighter;
16 16
use std::collections::HashSet;
21 21
struct Static;
22 22
23 23
#[derive(Clone)]
24 -
struct ServerConfig {
24 +
pub struct ServerConfig {
25 25
    api_key: Option<String>,
26 26
    auth_endpoints: HashSet<String>,
27 27
    max_content_size: usize,
48 48
}
49 49
50 50
#[derive(Clone)]
51 -
struct AppState {
52 -
    db: Db,
53 -
    highlighter: Arc<Highlighter>,
54 -
    server_config: ServerConfig,
55 -
    base_url: String,
51 +
pub struct AppState {
52 +
    pub db: Db,
53 +
    pub highlighter: Arc<Highlighter>,
54 +
    pub server_config: ServerConfig,
55 +
    pub base_url: String,
56 +
    pub cookie_secure: bool,
56 57
}
57 58
58 59
#[derive(Template)]
65 66
#[template(path = "admin.html")]
66 67
struct AdminTemplate {
67 68
    base_url: String,
69 +
    snippets: Vec<Snippet>,
70 +
}
71 +
72 +
#[derive(Template)]
73 +
#[template(path = "login.html")]
74 +
struct LoginTemplate {
75 +
    error: Option<String>,
76 +
    next: Option<String>,
68 77
}
69 78
70 79
#[derive(Template)]
82 91
    content: String,
83 92
}
84 93
94 +
#[derive(Deserialize)]
95 +
struct LoginForm {
96 +
    api_key: String,
97 +
}
98 +
99 +
#[derive(Deserialize, Default)]
100 +
struct FlashQuery {
101 +
    error: Option<String>,
102 +
    next: Option<String>,
103 +
}
104 +
85 105
async fn index(State(state): State<AppState>) -> WebTemplate<IndexTemplate> {
86 106
    WebTemplate(IndexTemplate { base_url: state.base_url.clone() })
87 107
}
88 108
89 -
async fn admin(State(state): State<AppState>) -> WebTemplate<AdminTemplate> {
90 -
    WebTemplate(AdminTemplate { base_url: state.base_url.clone() })
109 +
async fn admin(
110 +
    _session: auth::AuthSession,
111 +
    State(state): State<AppState>,
112 +
) -> Response {
113 +
    match db::get_all_snippets(&state.db) {
114 +
        Ok(snippets) => WebTemplate(AdminTemplate {
115 +
            base_url: state.base_url.clone(),
116 +
            snippets,
117 +
        })
118 +
        .into_response(),
119 +
        Err(_) => (
120 +
            StatusCode::INTERNAL_SERVER_ERROR,
121 +
            Html("<h1>Internal server error</h1>".to_string()),
122 +
        )
123 +
            .into_response(),
124 +
    }
125 +
}
126 +
127 +
async fn get_login(Query(q): Query<FlashQuery>) -> WebTemplate<LoginTemplate> {
128 +
    WebTemplate(LoginTemplate {
129 +
        error: q.error,
130 +
        next: q.next,
131 +
    })
132 +
}
133 +
134 +
async fn post_login(
135 +
    State(state): State<AppState>,
136 +
    Query(q): Query<FlashQuery>,
137 +
    Form(form): Form<LoginForm>,
138 +
) -> Response {
139 +
    let next = q.next.as_deref().unwrap_or("/admin");
140 +
    let server_key = match &state.server_config.api_key {
141 +
        Some(k) => k,
142 +
        None => {
143 +
            return Redirect::to("/admin/login?error=No+API+key+configured").into_response();
144 +
        }
145 +
    };
146 +
147 +
    if !auth::verify_api_key(&form.api_key, server_key) {
148 +
        return Redirect::to(&format!(
149 +
            "/admin/login?error=Invalid+API+key&next={}",
150 +
            auth::urlencoding(next)
151 +
        ))
152 +
        .into_response();
153 +
    }
154 +
155 +
    let token = auth::generate_session_token();
156 +
    let expires_at = andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600);
157 +
158 +
    if db::insert_session(&state.db, &token, &expires_at).is_err() {
159 +
        return Redirect::to("/admin/login?error=Server+error").into_response();
160 +
    }
161 +
    let _ = db::prune_expired_sessions(&state.db);
162 +
163 +
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
164 +
    let redirect_to = if next.starts_with('/') { next } else { "/admin" };
165 +
    let mut resp = Redirect::to(redirect_to).into_response();
166 +
    resp.headers_mut().insert(
167 +
        header::SET_COOKIE,
168 +
        HeaderValue::from_str(&cookie).unwrap(),
169 +
    );
170 +
    resp
171 +
}
172 +
173 +
async fn post_logout(
174 +
    State(state): State<AppState>,
175 +
    headers: HeaderMap,
176 +
) -> Response {
177 +
    if let Some(token) = andromeda_auth::extract_session_cookie(&headers) {
178 +
        let _ = db::delete_session(&state.db, &token);
179 +
    }
180 +
    let cookie = auth::clear_session_cookie();
181 +
    let mut resp = Redirect::to("/admin/login").into_response();
182 +
    resp.headers_mut().insert(
183 +
        header::SET_COOKIE,
184 +
        HeaderValue::from_str(&cookie).unwrap(),
185 +
    );
186 +
    resp
187 +
}
188 +
189 +
async fn admin_delete_snippet(
190 +
    _session: auth::AuthSession,
191 +
    State(state): State<AppState>,
192 +
    Path(short_id): Path<String>,
193 +
) -> Response {
194 +
    let _ = db::delete_snippet_by_short_id(&state.db, &short_id);
195 +
    Redirect::to("/admin").into_response()
91 196
}
92 197
93 198
fn is_cli_user_agent(headers: &HeaderMap) -> bool {
175 280
    let provided = headers
176 281
        .get("x-api-key")
177 282
        .and_then(|v| v.to_str().ok());
178 -
    match provided {
179 -
        Some(k) if k.as_bytes().ct_eq(server_key.as_bytes()).into() => Ok(next.run(request).await),
180 -
        _ => Err((
181 -
            StatusCode::UNAUTHORIZED,
182 -
            Json(serde_json::json!({"error": "Invalid or missing API key"})),
183 -
        )),
283 +
    if let Some(k) = provided {
284 +
        if auth::verify_api_key(k, server_key) {
285 +
            return Ok(next.run(request).await);
286 +
        }
287 +
    }
288 +
    if let Some(token) = andromeda_auth::extract_session_cookie(&headers) {
289 +
        if auth::is_valid_session(&state, &token) {
290 +
            return Ok(next.run(request).await);
291 +
        }
184 292
    }
293 +
    Err((
294 +
        StatusCode::UNAUTHORIZED,
295 +
        Json(serde_json::json!({"error": "Invalid or missing API key"})),
296 +
    ))
185 297
}
186 298
187 299
async fn api_list_snippets(
369 481
370 482
    let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
371 483
484 +
    let cookie_secure = std::env::var("SIPP_COOKIE_SECURE")
485 +
        .map(|v| v == "true")
486 +
        .unwrap_or(false);
487 +
488 +
    let db = db::init_db().expect("Failed to initialize database");
489 +
    let _ = db::prune_expired_sessions(&db);
490 +
372 491
    let state = AppState {
373 -
        db: db::init_db().expect("Failed to initialize database"),
492 +
        db,
374 493
        highlighter: Arc::new(Highlighter::new()),
375 494
        server_config,
376 495
        base_url,
496 +
        cookie_secure,
377 497
    };
378 498
379 499
    let api_routes = build_api_routes(&state);
381 501
    let app = Router::new()
382 502
        .route("/", get(index))
383 503
        .route("/admin", get(admin))
504 +
        .route("/admin/login", get(get_login).post(post_login))
505 +
        .route("/admin/logout", post(post_logout))
506 +
        .route("/admin/snippets/{short_id}/delete", post(admin_delete_snippet))
384 507
        .route("/s/{short_id}", get(view_snippet))
385 508
        .route("/snippets", post(create_snippet))
386 509
        .merge(api_routes)
apps/sipp/static/styles.css +55 −0
141 141
	border: 1px solid white;
142 142
	margin-top: -1px;
143 143
	text-decoration: none;
144 +
	gap: 8px;
144 145
}
145 146
146 147
.snippet-item:hover {
147 148
	background: #1e1d1f;
148 149
}
149 150
151 +
.snippet-link {
152 +
	display: flex;
153 +
	justify-content: space-between;
154 +
	align-items: center;
155 +
	flex: 1;
156 +
	text-decoration: none;
157 +
	gap: 8px;
158 +
}
159 +
150 160
.snippet-id {
151 161
	color: #878787;
152 162
	font-size: 13px;
163 +
}
164 +
165 +
.nav-link {
166 +
	color: #878787;
167 +
	text-decoration: none;
168 +
	background: transparent;
169 +
	border: none;
170 +
	cursor: pointer;
171 +
	font-size: inherit;
172 +
	font-family: inherit;
173 +
	padding: 0;
174 +
}
175 +
176 +
.nav-link:hover {
177 +
	color: white;
178 +
}
179 +
180 +
.nav-form {
181 +
	display: inline;
182 +
	margin: 0;
183 +
	padding: 0;
184 +
}
185 +
186 +
.inline-form {
187 +
	display: inline;
188 +
	margin: 0;
189 +
	padding: 0;
190 +
}
191 +
192 +
.link-button {
193 +
	background: transparent;
194 +
	border: none;
195 +
	color: #878787;
196 +
	cursor: pointer;
197 +
	padding: 0;
198 +
	font-family: inherit;
199 +
	font-size: 13px;
200 +
}
201 +
202 +
.link-button:hover {
203 +
	color: #ff6b6b;
204 +
}
205 +
206 +
.error {
207 +
	color: #ff6b6b;
153 208
}
154 209
155 210
@font-face {
apps/sipp/templates/admin.html +19 −66
32 32
        <h1>SIPP</h1>
33 33
      </a>
34 34
35 -
      <a class="icon" target="_blank" href="https://github.com/stevedylandev/sipp">
36 -
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
37 -
          <title>GitHub</title>
38 -
          <path d="m21.838 11.677l-9.549-9.58c-.129-.13-.451-.13-.645 0L9 4.742l2.452 2.452c.193-.097.419-.13.645-.13c.903 0 1.58.742 1.58 1.581c0 .226-.032.452-.129.645l1.968 1.968c.194-.097.42-.129.645-.129c.904 0 1.58.742 1.58 1.58c0 .904-.741 1.581-1.58 1.581c-.903 0-1.58-.742-1.58-1.58c0-.226.032-.452.129-.646l-1.968-1.967h-.032v3.71c.58.258 1 .806 1 1.483c0 .904-.742 1.581-1.581 1.581c-.903 0-1.58-.742-1.58-1.58c0-.678.419-1.259 1-1.485v-3.612c-.581-.259-1-.807-1-1.484c0-.226.032-.452.128-.645L8.225 5.613l-6.097 6.064c-.129.13-.129.452 0 .646l9.58 9.58c.13.13.452.13.646 0l9.548-9.58a.59.59 0 0 0-.064-.646"/>
39 -
        </svg>
40 -
      </a>
35 +
      <form method="POST" action="/admin/logout" class="nav-form">
36 +
        <button type="submit" class="nav-link">logout</button>
37 +
      </form>
41 38
    </div>
42 39
43 -
    <div id="authForm" style="display: flex; gap: 1rem; width: 100%;">
44 -
      <input placeholder="API Key" type="password" id="apiKey" style="flex: 1;">
45 -
      <button id="loadBtn" onclick="loadSnippets()">Load Snippets</button>
40 +
    <div id="snippetList">
41 +
      {% if snippets.is_empty() %}
42 +
        <p>No snippets found.</p>
43 +
      {% else %}
44 +
        {% for s in snippets %}
45 +
          <div class="snippet-item">
46 +
            <a class="snippet-link" href="/s/{{ s.short_id }}">
47 +
              <span class="snippet-name">{{ s.name }}</span>
48 +
              <span class="snippet-id">/s/{{ s.short_id }}</span>
49 +
            </a>
50 +
            <form method="POST" action="/admin/snippets/{{ s.short_id }}/delete" class="inline-form" onsubmit="return confirm('delete this snippet?')">
51 +
              <button type="submit" class="link-button">delete</button>
52 +
            </form>
53 +
          </div>
54 +
        {% endfor %}
55 +
      {% endif %}
46 56
    </div>
47 -
48 -
    <div id="error" style="display: none; color: #ff6b6b;"></div>
49 -
50 -
    <div id="snippetList" style="display: none; width: 100%;"></div>
51 -
52 -
    <script>
53 -
      async function loadSnippets() {
54 -
        const apiKey = document.getElementById('apiKey').value;
55 -
        const errorEl = document.getElementById('error');
56 -
        const listEl = document.getElementById('snippetList');
57 -
        const loadBtn = document.getElementById('loadBtn');
58 -
59 -
        errorEl.style.display = 'none';
60 -
        listEl.style.display = 'none';
61 -
        loadBtn.textContent = 'Loading...';
62 -
        loadBtn.disabled = true;
63 -
64 -
        try {
65 -
          const res = await fetch('/api/snippets', {
66 -
            headers: { 'x-api-key': apiKey }
67 -
          });
68 -
69 -
          if (!res.ok) {
70 -
            const data = await res.json();
71 -
            throw new Error(data.error || 'Failed to load snippets');
72 -
          }
73 -
74 -
          const snippets = await res.json();
75 -
76 -
          if (snippets.length === 0) {
77 -
            listEl.innerHTML = '<p>No snippets found.</p>';
78 -
          } else {
79 -
            listEl.innerHTML = snippets.map(s =>
80 -
              `<a class="snippet-item" href="/s/${s.short_id}">` +
81 -
                `<span class="snippet-name">${s.name}</span>` +
82 -
                `<span class="snippet-id">/s/${s.short_id}</span>` +
83 -
              `</a>`
84 -
            ).join('');
85 -
          }
86 -
87 -
          listEl.style.display = 'flex';
88 -
        } catch (err) {
89 -
          errorEl.textContent = err.message;
90 -
          errorEl.style.display = 'block';
91 -
        } finally {
92 -
          loadBtn.textContent = 'Load Snippets';
93 -
          loadBtn.disabled = false;
94 -
        }
95 -
      }
96 -
97 -
      document.getElementById('apiKey').addEventListener('keydown', (e) => {
98 -
        if (e.key === 'Enter') {
99 -
          e.preventDefault();
100 -
          loadSnippets();
101 -
        }
102 -
      });
103 -
    </script>
104 57
  </body>
105 58
</html>
apps/sipp/templates/index.html +1 −6
32 32
        <h1>SIPP</h1>
33 33
      </a>
34 34
35 -
      <a class="icon" target="_blank" href="https://github.com/stevedylandev/sipp">
36 -
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
37 -
          <title>GitHub</title>
38 -
          <path d="m21.838 11.677l-9.549-9.58c-.129-.13-.451-.13-.645 0L9 4.742l2.452 2.452c.193-.097.419-.13.645-.13c.903 0 1.58.742 1.58 1.581c0 .226-.032.452-.129.645l1.968 1.968c.194-.097.42-.129.645-.129c.904 0 1.58.742 1.58 1.58c0 .904-.741 1.581-1.58 1.581c-.903 0-1.58-.742-1.58-1.58c0-.226.032-.452.129-.646l-1.968-1.967h-.032v3.71c.58.258 1 .806 1 1.483c0 .904-.742 1.581-1.581 1.581c-.903 0-1.58-.742-1.58-1.58c0-.678.419-1.259 1-1.485v-3.612c-.581-.259-1-.807-1-1.484c0-.226.032-.452.128-.645L8.225 5.613l-6.097 6.064c-.129.13-.129.452 0 .646l9.58 9.58c.13.13.452.13.646 0l9.548-9.58a.59.59 0 0 0-.064-.646"/>
39 -
        </svg>
40 -
      </a>
35 +
      <a class="nav-link" href="/admin">admin</a>
41 36
    </div>
42 37
43 38
apps/sipp/templates/login.html (added) +35 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/static/styles.css" />
8 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
9 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
10 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
11 +
    <link rel="manifest" href="/static/site.webmanifest">
12 +
13 +
    <title>Sipp - Login</title>
14 +
    <meta name="description" content="Minimal Code Sharing">
15 +
  </head>
16 +
  <body>
17 +
18 +
    <div class="nav">
19 +
      <a href="/" class="header">
20 +
        <h1>SIPP</h1>
21 +
      </a>
22 +
    </div>
23 +
24 +
    {% if let Some(error) = error %}
25 +
      <p class="error">{{ error }}</p>
26 +
    {% endif %}
27 +
28 +
    <form id="loginForm" method="POST" action="/admin/login{% if let Some(next) = next %}?next={{ next }}{% endif %}">
29 +
      <div>
30 +
        <input placeholder="API Key" type="password" id="api_key" name="api_key" autofocus required>
31 +
      </div>
32 +
      <button type="submit">Login</button>
33 +
    </form>
34 +
  </body>
35 +
</html>
apps/sipp/templates/snippet.html +1 −6
32 32
        <h1>SIPP</h1>
33 33
      </a>
34 34
35 -
      <a class="icon" target="_blank" href="https://github.com/stevedylandev/sipp">
36 -
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
37 -
          <title>GitHub</title>
38 -
          <path d="m21.838 11.677l-9.549-9.58c-.129-.13-.451-.13-.645 0L9 4.742l2.452 2.452c.193-.097.419-.13.645-.13c.903 0 1.58.742 1.58 1.581c0 .226-.032.452-.129.645l1.968 1.968c.194-.097.42-.129.645-.129c.904 0 1.58.742 1.58 1.58c0 .904-.741 1.581-1.58 1.581c-.903 0-1.58-.742-1.58-1.58c0-.226.032-.452.129-.646l-1.968-1.967h-.032v3.71c.58.258 1 .806 1 1.483c0 .904-.742 1.581-1.581 1.581c-.903 0-1.58-.742-1.58-1.58c0-.678.419-1.259 1-1.485v-3.612c-.581-.259-1-.807-1-1.484c0-.226.032-.452.128-.645L8.225 5.613l-6.097 6.064c-.129.13-.129.452 0 .646l9.58 9.58c.13.13.452.13.646 0l9.548-9.58a.59.59 0 0 0-.064-.646"/>
39 -
        </svg>
40 -
      </a>
35 +
      <a class="nav-link" href="/admin">admin</a>
41 36
    </div>
42 37
43 38
    <div id="snippetForm">