Merge pull request #32 from stevedylandev/chore/sipp-add-andromeda-auth 7aa23575
Steve Simkins · 2026-04-18 15:41 21 file(s) · +457 −160
Cargo.lock +2 −1
4202 4202
4203 4203
[[package]]
4204 4204
name = "sipp-so"
4205 -
version = "0.1.6"
4205 +
version = "0.2.0"
4206 4206
dependencies = [
4207 +
 "andromeda-auth",
4207 4208
 "andromeda-db",
4208 4209
 "arboard",
4209 4210
 "askama 0.15.6",
apps/backup/.env.example +1 −0
10 10
# SIPP_VOLUME=sipp_sipp-data
11 11
# CELLAR_VOLUME=cellar_cellar-data
12 12
# POSTS_VOLUME=posts_posts-data
13 +
# FEEDS_VOLUME=feeds_feeds-data
13 14
14 15
# Optional: days to keep backups (default: 30)
15 16
# RETENTION_DAYS=30
apps/backup/README.md +4 −1
1 1
# Backup
2 2
3 -
Automated SQLite backups for Jotts, Sipp, Cellar, and Posts to Cloudflare R2. Runs every 6 hours via cron inside a Docker container and prunes backups older than 30 days.
3 +
Automated SQLite backups for Jotts, Sipp, Cellar, Posts, and Feeds to Cloudflare R2. Runs every 6 hours via cron inside a Docker container and prunes backups older than 30 days.
4 4
5 5
## Setup
6 6
45 45
SIPP_VOLUME=sipp_sipp-data
46 46
CELLAR_VOLUME=cellar_cellar-data
47 47
POSTS_VOLUME=posts_posts-data
48 +
FEEDS_VOLUME=feeds_feeds-data
48 49
```
49 50
50 51
Run `docker volume ls` to check the actual names on your host.
83 84
  -v sipp_sipp-data:/data/sipp:ro \
84 85
  -v cellar_cellar-data:/data/cellar:ro \
85 86
  -v posts_posts-data:/data/posts:ro \
87 +
  -v feeds_feeds-data:/data/feeds:ro \
86 88
  ghcr.io/stevedylandev/andromeda-backup:latest
87 89
```
88 90
151 153
| `SIPP_VOLUME` | `sipp_sipp-data` | Docker volume name for Sipp data |
152 154
| `CELLAR_VOLUME` | `cellar_cellar-data` | Docker volume name for Cellar data |
153 155
| `POSTS_VOLUME` | `posts_posts-data` | Docker volume name for Posts data |
156 +
| `FEEDS_VOLUME` | `feeds_feeds-data` | Docker volume name for Feeds data |
apps/backup/backup.sh +2 −2
5 5
BUCKET="${R2_BUCKET:-andromeda-backups}"
6 6
RETENTION_DAYS="${RETENTION_DAYS:-30}"
7 7
8 -
DBS="jotts:/data/jotts/jotts.sqlite sipp:/data/sipp/sipp.sqlite cellar:/data/cellar/cellar.sqlite posts:/data/posts/posts.sqlite"
8 +
DBS="jotts:/data/jotts/jotts.sqlite sipp:/data/sipp/sipp.sqlite cellar:/data/cellar/cellar.sqlite posts:/data/posts/posts.sqlite feeds:/data/feeds/feeds.sqlite"
9 9
10 10
for entry in $DBS; do
11 11
  name="${entry%%:*}"
28 28
29 29
# Prune old backups
30 30
cutoff=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || date -u -v-${RETENTION_DAYS}d +%Y-%m-%d)
31 -
for name in jotts sipp cellar posts; do
31 +
for name in jotts sipp cellar posts feeds; do
32 32
  aws s3 ls "s3://${BUCKET}/${name}/" --endpoint-url "${R2_ENDPOINT}" 2>/dev/null | while read -r line; do
33 33
    filedate=$(echo "$line" | awk '{print $1}')
34 34
    filename=$(echo "$line" | awk '{print $4}')
apps/backup/docker-compose.yml +4 −0
6 6
      - sipp-data:/data/sipp:ro
7 7
      - cellar-data:/data/cellar:ro
8 8
      - posts-data:/data/posts:ro
9 +
      - feeds-data:/data/feeds:ro
9 10
    env_file: .env
10 11
    restart: unless-stopped
11 12
22 23
  posts-data:
23 24
    external: true
24 25
    name: ${POSTS_VOLUME:-posts_posts-data}
26 +
  feeds-data:
27 +
    external: true
28 +
    name: ${FEEDS_VOLUME:-feeds_feeds-data}
apps/feeds/.env.example +1 −1
3 3
BASE_URL=http://localhost:3000
4 4
HOST=127.0.0.1
5 5
PORT=3000
6 -
DB_PATH=feeds.sqlite
6 +
FEEDS_DB_PATH=/data/feeds.sqlite
7 7
API_KEY=
8 8
DEFAULT_POLL_MINUTES=30
9 9
ITEM_CAP_PER_FEED=200
apps/feeds/Dockerfile +3 −1
36 36
37 37
FROM debian:bookworm-slim
38 38
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
39 -
WORKDIR /data
40 39
COPY --from=builder /app/target/release/feeds /usr/local/bin/feeds
40 +
WORKDIR /data
41 41
EXPOSE 3000
42 +
ENV HOST=0.0.0.0
43 +
ENV PORT=3000
42 44
CMD ["feeds"]
apps/feeds/README.md +2 −2
36 36
| `BASE_URL` | Public base URL of the app | `http://localhost:3000` |
37 37
| `HOST` | Bind address | `0.0.0.0` |
38 38
| `PORT` | Bind port | `3000` |
39 -
| `DB_PATH` | SQLite database path | `feeds.sqlite` |
39 +
| `FEEDS_DB_PATH` | SQLite database path | `/data/feeds.sqlite` |
40 40
| `DEFAULT_POLL_MINUTES` | Background poll interval in minutes (overridable from the admin panel) | `30` |
41 41
| `ITEM_CAP_PER_FEED` | Maximum stored items per subscription; older items pruned | `200` |
42 42
| `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` |
146 146
docker compose up -d
147 147
```
148 148
149 -
Mount a volume at `DB_PATH` to persist the SQLite database.
149 +
Mount a volume at `FEEDS_DB_PATH` to persist the SQLite database.
150 150
151 151
### Binary
152 152
apps/feeds/docker-compose.yml +12 −6
1 1
services:
2 -
  feeds:
2 +
  app:
3 3
    build:
4 4
      context: ../..
5 5
      dockerfile: apps/feeds/Dockerfile
6 6
    ports:
7 -
      - "3000:3000"
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
10 +
      - API_KEY=${API_KEY:-}
11 +
      - FEEDS_DB_PATH=/data/feeds.sqlite
12 +
      - BASE_URL=${BASE_URL:-http://localhost:3000}
13 +
      - COOKIE_SECURE=false
14 +
      - HOST=0.0.0.0
15 +
      - PORT=${PORT:-3000}
8 16
    volumes:
9 -
      - feeds_data:/app/data
10 -
    env_file:
11 -
      - .env
17 +
      - feeds-data:/data
12 18
    restart: unless-stopped
13 19
14 20
volumes:
15 -
  feeds_data:
21 +
  feeds-data:
apps/feeds/src/main.rs +2 −1
558 558
        )
559 559
        .init();
560 560
561 -
    let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "feeds.sqlite".to_string());
561 +
    let db_path =
562 +
        std::env::var("FEEDS_DB_PATH").unwrap_or_else(|_| "/data/feeds.sqlite".to_string());
562 563
    let conn = Connection::open(&db_path).expect("open sqlite");
563 564
    conn.execute_batch(SESSION_SCHEMA).expect("session schema");
564 565
    conn.execute_batch(fdb::FEEDS_SCHEMA).expect("feeds schema");
apps/sipp/Cargo.toml +3 −2
1 1
[package]
2 2
name = "sipp-so"
3 -
version = "0.1.6"
3 +
version = "0.2.0"
4 4
edition = "2024"
5 5
description = "Minimal code sharing - single binary for web server, CLI, and TUI"
6 6
license = "MIT"
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/docker-compose.yml +1 −0
8 8
    environment:
9 9
      - SIPP_API_KEY=${SIPP_API_KEY:-changeme}
10 10
      - SIPP_AUTH_ENDPOINTS=api_delete,api_list
11 +
      - SIPP_DB_PATH=/data/sipp.sqlite
11 12
    volumes:
12 13
      - sipp-data:/data
13 14
    restart: unless-stopped
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 +149 −35
10 10
html {
11 11
	background: #121113;
12 12
	color: #ffffff;
13 +
	font-size: 14px;
14 +
	line-height: 1.6;
15 +
}
16 +
17 +
a {
18 +
	color: #ffffff;
19 +
	text-decoration: none;
20 +
}
21 +
22 +
a:hover {
23 +
	opacity: 0.7;
13 24
}
14 25
15 26
html::-webkit-scrollbar {
25 36
	min-height: 100vh;
26 37
	max-width: 700px;
27 38
	margin: auto;
39 +
	padding: 0 1rem 4rem;
28 40
}
29 41
30 42
.header {
31 43
	display: flex;
32 -
	text-decoration: none;
44 +
	flex-direction: column;
45 +
	gap: 0.5rem;
46 +
	margin-top: 2rem;
47 +
}
48 +
49 +
.logo {
50 +
	text-decoration: none !important;
33 51
}
34 52
35 -
.nav {
53 +
.links {
36 54
	display: flex;
37 55
	align-items: center;
38 -
	justify-content: space-between;
39 -
	width: 100%;
40 -
	margin-top: 2rem;
56 +
	gap: 0.75rem;
57 +
	font-size: 12px;
58 +
	text-decoration: none;
59 +
}
60 +
61 +
.links .inline-form {
62 +
	display: inline;
63 +
	margin: 0;
64 +
	padding: 0;
65 +
}
66 +
67 +
.links .link-button {
68 +
	font-size: 12px;
41 69
}
42 70
43 71
.icon {
69 97
	width: 100%;
70 98
}
71 99
72 -
#snippetForm input {
73 -
	background: #121113;
74 -
	color: #ffffff;
75 -
	border: 1px solid white;
76 -
	padding: 4px;
77 -
}
78 -
79 -
#authForm input {
100 +
input, textarea, select {
80 101
	background: #121113;
81 102
	color: #ffffff;
82 103
	border: 1px solid white;
83 -
	padding: 4px;
104 +
	padding: 0.4rem 0.75rem;
105 +
	font-size: 16px;
106 +
	width: 100%;
107 +
	border-radius: 0;
84 108
}
85 109
86 110
textarea {
87 -
	background: #121113;
88 -
	color: #ffffff;
89 -
	width: 100%;
90 111
	min-height: 400px;
91 -
	padding: 6px;
92 -
	border: 1px solid white;
112 +
	resize: vertical;
93 113
}
94 114
95 115
.code-container {
107 127
	line-height: 1.4;
108 128
}
109 129
110 -
button {
130 +
button, .btn {
111 131
	background: #121113;
112 132
	color: #ffffff;
113 -
	padding: 6px;
133 +
	padding: 0.4rem 0.75rem;
114 134
	border: 1px solid white;
115 135
	cursor: pointer;
116 136
	width: fit-content;
137 +
	font-size: 14px;
138 +
	border-radius: 0;
139 +
	text-decoration: none;
140 +
	display: inline-block;
117 141
}
118 142
119 -
a {
120 -
	background: #121113;
121 -
	color: #ffffff;
143 +
button:hover, .btn:hover {
144 +
	opacity: 0.7;
122 145
}
123 146
124 147
@media (max-width: 480px) {
128 151
	}
129 152
}
130 153
131 -
#snippetList {
154 +
.empty {
155 +
	opacity: 0.5;
156 +
	font-size: 12px;
157 +
}
158 +
159 +
.admin-list {
160 +
	display: flex;
132 161
	flex-direction: column;
133 -
	gap: 0;
162 +
	width: 100%;
134 163
}
135 164
136 -
.snippet-item {
165 +
.admin-list-item {
137 166
	display: flex;
138 167
	justify-content: space-between;
139 168
	align-items: center;
140 -
	padding: 8px;
141 -
	border: 1px solid white;
142 -
	margin-top: -1px;
143 -
	text-decoration: none;
169 +
	padding: 8px 0;
170 +
	border-bottom: 1px solid #333;
171 +
	gap: 1rem;
144 172
}
145 173
146 -
.snippet-item:hover {
147 -
	background: #1e1d1f;
174 +
.admin-list-info {
175 +
	display: flex;
176 +
	flex-direction: column;
177 +
	gap: 0.2rem;
178 +
	min-width: 0;
179 +
}
180 +
181 +
.admin-list-title {
182 +
	font-size: 15px;
183 +
	white-space: nowrap;
184 +
	overflow: hidden;
185 +
	text-overflow: ellipsis;
148 186
}
149 187
150 -
.snippet-id {
151 -
	color: #878787;
188 +
.admin-list-meta {
189 +
	display: flex;
190 +
	gap: 0.75rem;
191 +
	align-items: center;
192 +
}
193 +
194 +
.admin-list-date {
195 +
	font-size: 11px;
196 +
	opacity: 0.4;
197 +
}
198 +
199 +
.admin-list-actions {
200 +
	display: flex;
201 +
	gap: 1rem;
202 +
	font-size: 12px;
203 +
	flex-shrink: 0;
204 +
}
205 +
206 +
.inline-form {
207 +
	display: inline;
208 +
	margin: 0;
209 +
	padding: 0;
210 +
}
211 +
212 +
.link-button {
213 +
	background: none;
214 +
	border: none;
215 +
	color: #ffffff;
216 +
	cursor: pointer;
217 +
	font-size: 12px;
218 +
	padding: 0;
219 +
	font-family: inherit;
220 +
}
221 +
222 +
.link-button:hover {
223 +
	opacity: 0.7;
224 +
}
225 +
226 +
.link-button.danger {
227 +
	opacity: 0.5;
228 +
}
229 +
230 +
.link-button.danger:hover {
231 +
	opacity: 0.3;
232 +
}
233 +
234 +
@media (max-width: 480px) {
235 +
	.admin-list-item {
236 +
		flex-direction: column;
237 +
		align-items: flex-start;
238 +
		gap: 0.5rem;
239 +
	}
240 +
}
241 +
242 +
main {
243 +
	width: 100%;
244 +
	display: flex;
245 +
	flex-direction: column;
246 +
	gap: 1rem;
247 +
}
248 +
249 +
.form {
250 +
	display: flex;
251 +
	flex-direction: column;
252 +
	gap: 0.5rem;
253 +
	width: 100%;
254 +
}
255 +
256 +
label {
257 +
	font-size: 12px;
258 +
	opacity: 0.7;
259 +
}
260 +
261 +
.error {
262 +
	color: #ffffff;
263 +
	border-left: 2px solid #ffffff;
264 +
	padding-left: 0.5rem;
152 265
	font-size: 13px;
266 +
	opacity: 0.8;
153 267
}
154 268
155 269
@font-face {
apps/sipp/templates/admin.html +24 −72
27 27
  </head>
28 28
  <body>
29 29
30 -
    <div class="nav">
31 -
      <a href="/" class="header">
32 -
        <h1>SIPP</h1>
33 -
      </a>
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>
41 -
    </div>
42 -
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>
30 +
    <div class="header">
31 +
      <a href="/" class="logo"><h1>SIPP</h1></a>
46 32
    </div>
47 33
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>
34 +
    {% if snippets.is_empty() %}
35 +
      <p class="empty">no snippets yet</p>
36 +
    {% else %}
37 +
      <div class="admin-list">
38 +
        {% for s in snippets %}
39 +
          <div class="admin-list-item">
40 +
            <div class="admin-list-info">
41 +
              <a href="/s/{{ s.short_id }}" class="admin-list-title">{{ s.name }}</a>
42 +
              <div class="admin-list-meta">
43 +
                <span class="admin-list-date">/s/{{ s.short_id }}</span>
44 +
              </div>
45 +
            </div>
46 +
            <div class="admin-list-actions">
47 +
              <a href="/s/{{ s.short_id }}">view</a>
48 +
              <form method="POST" action="/admin/snippets/{{ s.short_id }}/delete" class="inline-form" onsubmit="return confirm('delete this snippet?')">
49 +
                <button type="submit" class="link-button danger">delete</button>
50 +
              </form>
51 +
            </div>
52 +
          </div>
53 +
        {% endfor %}
54 +
      </div>
55 +
    {% endif %}
104 56
  </body>
105 57
</html>
apps/sipp/templates/index.html +5 −11
27 27
  </head>
28 28
  <body>
29 29
30 -
    <div class="nav">
31 -
      <a href="/" class="header">
32 -
        <h1>SIPP</h1>
33 -
      </a>
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>
30 +
    <div class="header">
31 +
      <a href="/" class="logo"><h1>SIPP</h1></a>
32 +
      <nav class="links">
33 +
        <a href="/admin">admin</a>
34 +
      </nav>
41 35
    </div>
42 36
43 37
apps/sipp/templates/login.html (added) +33 −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 +
    <header class="header">
19 +
      <a href="/" class="logo"><h1>SIPP</h1></a>
20 +
    </header>
21 +
    <main>
22 +
      {% if let Some(error) = error %}
23 +
        <p class="error">{{ error }}</p>
24 +
      {% endif %}
25 +
26 +
      <form method="POST" action="/admin/login{% if let Some(next) = next %}?next={{ next }}{% endif %}" class="form">
27 +
        <label for="api_key">api key</label>
28 +
        <input type="password" id="api_key" name="api_key" autofocus required>
29 +
        <button type="submit">login</button>
30 +
      </form>
31 +
    </main>
32 +
  </body>
33 +
</html>
apps/sipp/templates/snippet.html +0 −7
31 31
      <a href="/" class="header">
32 32
        <h1>SIPP</h1>
33 33
      </a>
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>
41 34
    </div>
42 35
43 36
    <div id="snippetForm">