Merge pull request #38 from stevedylandev/feat/add-bookmarks-app 80553082
feat: init bookmarks
Steve Simkins · 2026-04-25 22:53 30 file(s) · +1401 −4
.github/workflows/docker-test.yml +2 −2
19 19
      - name: Determine which apps to build
20 20
        id: filter
21 21
        run: |
22 -
          ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library"]'
22 +
          ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","bookmarks"]'
23 23
24 24
          changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
25 25
29 29
          fi
30 30
31 31
          apps=()
32 -
          for app in cellar sipp feeds parcels jotts og shrink backup posts library; do
32 +
          for app in cellar sipp feeds parcels jotts og shrink backup posts library bookmarks; do
33 33
            if echo "$changed" | grep -q "^apps/${app}/"; then
34 34
              apps+=("\"${app}\"")
35 35
            fi
.github/workflows/docker.yml +2 −2
25 25
      - name: Determine which apps to build
26 26
        id: filter
27 27
        run: |
28 -
          ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library"]'
28 +
          ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","bookmarks"]'
29 29
30 30
          # Map cargo package names to directory names
31 31
          pkg_to_dir() {
61 61
          fi
62 62
63 63
          apps=()
64 -
          for app in cellar sipp feeds parcels jotts og shrink backup posts library; do
64 +
          for app in cellar sipp feeds parcels jotts og shrink backup posts library bookmarks; do
65 65
            if echo "$changed" | grep -q "^apps/${app}/"; then
66 66
              apps+=("\"${app}\"")
67 67
            fi
Cargo.lock +24 −0
618 618
]
619 619
620 620
[[package]]
621 +
name = "bookmarks"
622 +
version = "0.1.0"
623 +
dependencies = [
624 +
 "andromeda-auth",
625 +
 "andromeda-darkmatter-css",
626 +
 "andromeda-db",
627 +
 "askama 0.13.1",
628 +
 "axum",
629 +
 "chrono",
630 +
 "dotenvy",
631 +
 "mime_guess",
632 +
 "nanoid",
633 +
 "rand 0.8.5",
634 +
 "rusqlite",
635 +
 "rust-embed",
636 +
 "serde",
637 +
 "serde_json",
638 +
 "subtle",
639 +
 "tokio",
640 +
 "tracing",
641 +
 "tracing-subscriber",
642 +
]
643 +
644 +
[[package]]
621 645
name = "built"
622 646
version = "0.8.0"
623 647
source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +1 −0
9 9
    "apps/cellar",
10 10
    "apps/posts",
11 11
    "apps/library",
12 +
    "apps/bookmarks",
12 13
    "crates/auth",
13 14
    "crates/db",
14 15
    "crates/darkmatter-css",
apps/bookmarks/.env.example (added) +14 −0
1 +
HOST=127.0.0.1
2 +
PORT=3000
3 +
4 +
# SQLite file path (default: bookmarks.sqlite)
5 +
BOOKMARKS_DB_PATH=bookmarks.sqlite
6 +
7 +
# Admin login password (required for /admin)
8 +
BOOKMARKS_PASSWORD=changeme
9 +
10 +
# API key for POST /api/links (omit to disable write API)
11 +
BOOKMARKS_API_KEY=
12 +
13 +
# Set true behind HTTPS to mark session cookie Secure
14 +
COOKIE_SECURE=false
apps/bookmarks/Cargo.toml (added) +28 −0
1 +
[package]
2 +
name = "bookmarks"
3 +
version = "0.1.0"
4 +
edition = "2024"
5 +
description = "Personal link saver"
6 +
license = "MIT"
7 +
repository = "https://github.com/stevedylandev/andromeda"
8 +
homepage = "https://github.com/stevedylandev/andromeda"
9 +
10 +
[dependencies]
11 +
axum = { workspace = true }
12 +
tokio = { workspace = true }
13 +
serde = { workspace = true }
14 +
serde_json = { workspace = true }
15 +
dotenvy = { workspace = true }
16 +
rusqlite = { workspace = true }
17 +
nanoid = { workspace = true }
18 +
rust-embed = { workspace = true }
19 +
mime_guess = "2"
20 +
subtle = { workspace = true }
21 +
rand = { workspace = true }
22 +
tracing = { workspace = true }
23 +
tracing-subscriber = { workspace = true, features = ["env-filter"] }
24 +
andromeda-auth = { workspace = true }
25 +
andromeda-db = { workspace = true, features = ["axum", "session"] }
26 +
andromeda-darkmatter-css = { workspace = true }
27 +
askama = "0.13"
28 +
chrono = "0.4"
apps/bookmarks/Dockerfile (added) +21 −0
1 +
FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef
2 +
WORKDIR /app
3 +
4 +
FROM chef AS planner
5 +
COPY . .
6 +
RUN cargo chef prepare --recipe-path recipe.json
7 +
8 +
FROM chef AS builder
9 +
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
10 +
COPY --from=planner /app/recipe.json recipe.json
11 +
RUN cargo chef cook --release --recipe-path recipe.json -p bookmarks
12 +
COPY . .
13 +
RUN cargo build --release -p bookmarks
14 +
15 +
FROM debian:bookworm-slim
16 +
COPY --from=builder /app/target/release/bookmarks /usr/local/bin/bookmarks
17 +
WORKDIR /data
18 +
EXPOSE 3000
19 +
ENV HOST=0.0.0.0
20 +
ENV PORT=3000
21 +
CMD ["bookmarks"]
apps/bookmarks/README.md (added) +118 −0
1 +
# Bookmarks
2 +
3 +
Personal link saver organized by category.
4 +
5 +
## Quickstart
6 +
7 +
1. Make sure [Rust](https://www.rust-lang.org/tools/install) is installed
8 +
9 +
```bash
10 +
rustc --version
11 +
```
12 +
13 +
2. Clone and build
14 +
15 +
```bash
16 +
git clone https://github.com/stevedylandev/andromeda
17 +
cd andromeda
18 +
cargo build -p bookmarks
19 +
```
20 +
21 +
3. Run the dev server
22 +
23 +
```bash
24 +
cargo run -p bookmarks
25 +
# Server running on http://localhost:3000
26 +
```
27 +
28 +
### Environment Variables
29 +
30 +
| Variable | Description | Default |
31 +
|---|---|---|
32 +
| `BOOKMARKS_PASSWORD` | Password for the admin panel | — |
33 +
| `BOOKMARKS_API_KEY` | API key for `POST /api/links` (omit to disable write API) | — |
34 +
| `BOOKMARKS_DB_PATH` | SQLite database path | `bookmarks.sqlite` |
35 +
| `HOST` | Bind address | `127.0.0.1` |
36 +
| `PORT` | Bind port | `3000` |
37 +
| `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` |
38 +
39 +
## Overview
40 +
41 +
Bookmarks is a single-user link saver. Add links via the admin panel or JSON API, organize them into categories, and view them on a public index page grouped by category. A few highlights:
42 +
43 +
- Single Rust binary with embedded assets
44 +
- Local SQLite storage
45 +
- Password-protected admin panel for managing categories and links
46 +
- JSON read API (open) and write API (key-guarded)
47 +
- Dark themed UI with Commit Mono font
48 +
49 +
## Usage
50 +
51 +
### Admin Panel
52 +
53 +
Set `BOOKMARKS_PASSWORD` and visit `/login`. From the admin panel you can:
54 +
55 +
- Create and remove categories
56 +
- Add links with title, URL, and category
57 +
- Remove links
58 +
59 +
### JSON API
60 +
61 +
Read endpoints are open. Write endpoints require `x-api-key: <BOOKMARKS_API_KEY>`.
62 +
63 +
| Method | Path | Auth | Purpose |
64 +
|---|---|---|---|
65 +
| `GET` | `/api/categories` | open | List categories |
66 +
| `GET` | `/api/links` | open | List links grouped by category. Query: `category` to filter by name |
67 +
| `POST` | `/api/links` | api key | Create link. Body: `{category, title, url}` |
68 +
69 +
Example:
70 +
71 +
```bash
72 +
curl -X POST http://localhost:3000/api/links \
73 +
  -H "x-api-key: $BOOKMARKS_API_KEY" \
74 +
  -H "content-type: application/json" \
75 +
  -d '{"category":"Reading","title":"Example","url":"https://example.com"}'
76 +
```
77 +
78 +
## Structure
79 +
80 +
```
81 +
bookmarks/
82 +
├── src/
83 +
│   ├── main.rs        # Axum server, admin routes, JSON API, static serving
84 +
│   ├── db.rs          # Schema and SQLite queries
85 +
│   └── auth.rs        # Session + API-key guards
86 +
├── templates/         # Askama HTML templates (index, login, admin)
87 +
├── static/            # Static assets embedded at compile time via rust-embed
88 +
├── Dockerfile
89 +
└── docker-compose.yml
90 +
```
91 +
92 +
## Deployment
93 +
94 +
Since Bookmarks compiles to a single binary, deployment is straightforward on any platform.
95 +
96 +
### Docker (recommended)
97 +
98 +
```bash
99 +
git clone https://github.com/stevedylandev/andromeda
100 +
cd andromeda/apps/bookmarks
101 +
cp .env.example .env
102 +
# Edit .env with your credentials
103 +
docker compose up -d
104 +
```
105 +
106 +
Mount a volume at `BOOKMARKS_DB_PATH` to persist the SQLite database.
107 +
108 +
### Binary
109 +
110 +
```bash
111 +
cargo build --release -p bookmarks
112 +
```
113 +
114 +
The resulting binary at `./target/release/bookmarks` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly.
115 +
116 +
## License
117 +
118 +
[MIT](../../LICENSE)
apps/bookmarks/askama.toml (added) +2 −0
1 +
[general]
2 +
dirs = ["src/templates"]
apps/bookmarks/docker-compose.yml (added) +20 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/bookmarks/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - BOOKMARKS_PASSWORD=${BOOKMARKS_PASSWORD:-changeme}
10 +
      - BOOKMARKS_API_KEY=${BOOKMARKS_API_KEY:-}
11 +
      - BOOKMARKS_DB_PATH=/data/bookmarks.sqlite
12 +
      - COOKIE_SECURE=false
13 +
      - HOST=0.0.0.0
14 +
      - PORT=${PORT:-3000}
15 +
    volumes:
16 +
      - bookmarks-data:/data
17 +
    restart: unless-stopped
18 +
19 +
volumes:
20 +
  bookmarks-data:
apps/bookmarks/src/auth.rs (added) +57 −0
1 +
use axum::{
2 +
    extract::{FromRef, FromRequestParts},
3 +
    http::request::Parts,
4 +
    response::{IntoResponse, Redirect, Response},
5 +
};
6 +
use chrono::{Duration, Utc};
7 +
use std::sync::Arc;
8 +
9 +
use crate::AppState;
10 +
use andromeda_db::session;
11 +
12 +
pub use andromeda_auth::{
13 +
    build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token,
14 +
    verify_api_key, verify_password,
15 +
};
16 +
17 +
const SESSION_DAYS: i64 = 7;
18 +
19 +
pub fn create_session(db: &andromeda_db::Db, token: &str) -> Result<(), andromeda_db::DbError> {
20 +
    let expires = (Utc::now() + Duration::days(SESSION_DAYS))
21 +
        .format("%Y-%m-%d %H:%M:%S")
22 +
        .to_string();
23 +
    session::insert_session(db, token, &expires)
24 +
}
25 +
26 +
pub fn is_valid_session(db: &andromeda_db::Db, token: &str) -> bool {
27 +
    match session::get_session_expiry(db, token) {
28 +
        Ok(Some(expires_at)) => chrono::NaiveDateTime::parse_from_str(&expires_at, "%Y-%m-%d %H:%M:%S")
29 +
            .map(|exp| exp > Utc::now().naive_utc())
30 +
            .unwrap_or(false),
31 +
        _ => false,
32 +
    }
33 +
}
34 +
35 +
pub fn delete_session(db: &andromeda_db::Db, token: &str) {
36 +
    let _ = session::delete_session(db, token);
37 +
}
38 +
39 +
pub struct AuthSession;
40 +
41 +
impl<S> FromRequestParts<S> for AuthSession
42 +
where
43 +
    S: Send + Sync,
44 +
    Arc<AppState>: FromRef<S>,
45 +
{
46 +
    type Rejection = Response;
47 +
48 +
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
49 +
        let state = Arc::<AppState>::from_ref(state);
50 +
        if let Some(token) = extract_session_cookie(&parts.headers) {
51 +
            if is_valid_session(&state.db, &token) {
52 +
                return Ok(AuthSession);
53 +
            }
54 +
        }
55 +
        Err(Redirect::to("/login").into_response())
56 +
    }
57 +
}
apps/bookmarks/src/db.rs (added) +135 −0
1 +
use andromeda_db::{Db, DbError};
2 +
use nanoid::nanoid;
3 +
use rusqlite::{OptionalExtension, params};
4 +
use serde::Serialize;
5 +
6 +
pub const SCHEMA: &str = r#"
7 +
CREATE TABLE IF NOT EXISTS categories (
8 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
9 +
    short_id   TEXT NOT NULL UNIQUE,
10 +
    name       TEXT NOT NULL UNIQUE,
11 +
    created_at INTEGER NOT NULL
12 +
);
13 +
14 +
CREATE TABLE IF NOT EXISTS links (
15 +
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
16 +
    short_id    TEXT NOT NULL UNIQUE,
17 +
    title       TEXT NOT NULL,
18 +
    url         TEXT NOT NULL,
19 +
    category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
20 +
    created_at  INTEGER NOT NULL
21 +
);
22 +
23 +
CREATE INDEX IF NOT EXISTS idx_links_category ON links(category_id, created_at DESC);
24 +
"#;
25 +
26 +
#[derive(Debug, Clone, Serialize)]
27 +
pub struct Category {
28 +
    pub id: i64,
29 +
    pub short_id: String,
30 +
    pub name: String,
31 +
}
32 +
33 +
#[derive(Debug, Clone, Serialize)]
34 +
pub struct Link {
35 +
    pub id: i64,
36 +
    pub short_id: String,
37 +
    pub title: String,
38 +
    pub url: String,
39 +
    pub category_id: i64,
40 +
    pub created_at: i64,
41 +
}
42 +
43 +
pub fn list_categories(db: &Db) -> Result<Vec<Category>, DbError> {
44 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
45 +
    let mut stmt = conn.prepare("SELECT id, short_id, name FROM categories ORDER BY name COLLATE NOCASE")?;
46 +
    let rows = stmt.query_map([], |row| {
47 +
        Ok(Category {
48 +
            id: row.get(0)?,
49 +
            short_id: row.get(1)?,
50 +
            name: row.get(2)?,
51 +
        })
52 +
    })?;
53 +
    Ok(rows.collect::<Result<Vec<_>, _>>()?)
54 +
}
55 +
56 +
pub fn create_category(db: &Db, name: &str) -> Result<Category, DbError> {
57 +
    let now = chrono::Utc::now().timestamp();
58 +
    let short_id = nanoid!(10);
59 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
60 +
    conn.execute(
61 +
        "INSERT INTO categories (short_id, name, created_at) VALUES (?1, ?2, ?3)",
62 +
        params![short_id, name, now],
63 +
    )?;
64 +
    Ok(Category {
65 +
        id: conn.last_insert_rowid(),
66 +
        short_id,
67 +
        name: name.to_string(),
68 +
    })
69 +
}
70 +
71 +
pub fn delete_category_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> {
72 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
73 +
    let n = conn.execute("DELETE FROM categories WHERE short_id = ?1", params![short_id])?;
74 +
    Ok(n > 0)
75 +
}
76 +
77 +
pub fn get_category_by_name(db: &Db, name: &str) -> Result<Option<Category>, DbError> {
78 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
79 +
    let cat = conn
80 +
        .query_row(
81 +
            "SELECT id, short_id, name FROM categories WHERE name = ?1",
82 +
            params![name],
83 +
            |row| {
84 +
                Ok(Category {
85 +
                    id: row.get(0)?,
86 +
                    short_id: row.get(1)?,
87 +
                    name: row.get(2)?,
88 +
                })
89 +
            },
90 +
        )
91 +
        .optional()?;
92 +
    Ok(cat)
93 +
}
94 +
95 +
pub fn list_links(db: &Db) -> Result<Vec<Link>, DbError> {
96 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
97 +
    let mut stmt = conn.prepare(
98 +
        "SELECT id, short_id, title, url, category_id, created_at FROM links ORDER BY created_at DESC",
99 +
    )?;
100 +
    let rows = stmt.query_map([], |row| {
101 +
        Ok(Link {
102 +
            id: row.get(0)?,
103 +
            short_id: row.get(1)?,
104 +
            title: row.get(2)?,
105 +
            url: row.get(3)?,
106 +
            category_id: row.get(4)?,
107 +
            created_at: row.get(5)?,
108 +
        })
109 +
    })?;
110 +
    Ok(rows.collect::<Result<Vec<_>, _>>()?)
111 +
}
112 +
113 +
pub fn create_link(db: &Db, title: &str, url: &str, category_id: i64) -> Result<Link, DbError> {
114 +
    let now = chrono::Utc::now().timestamp();
115 +
    let short_id = nanoid!(10);
116 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
117 +
    conn.execute(
118 +
        "INSERT INTO links (short_id, title, url, category_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
119 +
        params![short_id, title, url, category_id, now],
120 +
    )?;
121 +
    Ok(Link {
122 +
        id: conn.last_insert_rowid(),
123 +
        short_id,
124 +
        title: title.to_string(),
125 +
        url: url.to_string(),
126 +
        category_id,
127 +
        created_at: now,
128 +
    })
129 +
}
130 +
131 +
pub fn delete_link_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> {
132 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
133 +
    let n = conn.execute("DELETE FROM links WHERE short_id = ?1", params![short_id])?;
134 +
    Ok(n > 0)
135 +
}
apps/bookmarks/src/main.rs (added) +452 −0
1 +
mod auth;
2 +
mod db;
3 +
4 +
use std::sync::{Arc, Mutex};
5 +
6 +
use andromeda_db::{
7 +
    Db,
8 +
    session::{SESSION_SCHEMA, prune_expired_sessions},
9 +
};
10 +
use askama::Template;
11 +
use axum::{
12 +
    Form, Json, Router,
13 +
    extract::{Path, Query, Request, State},
14 +
    http::{HeaderMap, StatusCode, header},
15 +
    middleware::{self, Next},
16 +
    response::{Html, IntoResponse, Redirect, Response},
17 +
    routing::{get, post},
18 +
};
19 +
use rusqlite::Connection;
20 +
use rust_embed::Embed;
21 +
use serde::Deserialize;
22 +
23 +
#[derive(Embed)]
24 +
#[folder = "static/"]
25 +
struct Static;
26 +
27 +
async fn static_handler(Path(path): Path<String>) -> Response {
28 +
    match Static::get(&path) {
29 +
        Some(file) => {
30 +
            let mime = mime_guess::from_path(&path).first_or_octet_stream();
31 +
            ([(header::CONTENT_TYPE, mime.as_ref())], file.data.to_vec()).into_response()
32 +
        }
33 +
        None => StatusCode::NOT_FOUND.into_response(),
34 +
    }
35 +
}
36 +
37 +
use crate::db::{Category, Link};
38 +
39 +
pub struct AppState {
40 +
    pub db: Db,
41 +
    pub admin_password: Option<String>,
42 +
    pub api_key: Option<String>,
43 +
    pub cookie_secure: bool,
44 +
}
45 +
46 +
// ── Templates ────────────────────────────────────────────────────────────
47 +
48 +
struct CategoryGroup {
49 +
    name: String,
50 +
    links: Vec<Link>,
51 +
}
52 +
53 +
#[derive(Template)]
54 +
#[template(path = "index.html")]
55 +
struct IndexTemplate {
56 +
    groups: Vec<CategoryGroup>,
57 +
}
58 +
59 +
#[derive(Template)]
60 +
#[template(path = "login.html")]
61 +
struct LoginTemplate {
62 +
    error: Option<String>,
63 +
}
64 +
65 +
#[derive(Template)]
66 +
#[template(path = "admin.html")]
67 +
struct AdminTemplate {
68 +
    success: Option<String>,
69 +
    error: Option<String>,
70 +
    categories: Vec<Category>,
71 +
    links: Vec<AdminLinkRow>,
72 +
}
73 +
74 +
struct AdminLinkRow {
75 +
    short_id: String,
76 +
    title: String,
77 +
    url: String,
78 +
    category: String,
79 +
}
80 +
81 +
#[derive(Deserialize, Default)]
82 +
struct FlashQuery {
83 +
    error: Option<String>,
84 +
    success: Option<String>,
85 +
}
86 +
87 +
fn render<T: Template>(tpl: T) -> Response {
88 +
    match tpl.render() {
89 +
        Ok(html) => Html(html).into_response(),
90 +
        Err(e) => {
91 +
            tracing::error!("template render: {e}");
92 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
93 +
        }
94 +
    }
95 +
}
96 +
97 +
// ── Public web ───────────────────────────────────────────────────────────
98 +
99 +
async fn index_handler(State(state): State<Arc<AppState>>) -> Response {
100 +
    let categories = db::list_categories(&state.db).unwrap_or_default();
101 +
    let all_links = db::list_links(&state.db).unwrap_or_default();
102 +
    let groups = categories
103 +
        .into_iter()
104 +
        .map(|c| {
105 +
            let links = all_links
106 +
                .iter()
107 +
                .filter(|l| l.category_id == c.id)
108 +
                .cloned()
109 +
                .collect();
110 +
            CategoryGroup { name: c.name, links }
111 +
        })
112 +
        .collect();
113 +
    render(IndexTemplate { groups })
114 +
}
115 +
116 +
// ── Login / logout ───────────────────────────────────────────────────────
117 +
118 +
#[derive(Deserialize)]
119 +
struct LoginForm {
120 +
    password: String,
121 +
}
122 +
123 +
async fn login_get(Query(q): Query<FlashQuery>) -> Response {
124 +
    render(LoginTemplate { error: q.error })
125 +
}
126 +
127 +
async fn login_post(State(state): State<Arc<AppState>>, Form(form): Form<LoginForm>) -> Response {
128 +
    let pw = match &state.admin_password {
129 +
        Some(p) => p,
130 +
        None => return Redirect::to("/login?error=No+password+configured").into_response(),
131 +
    };
132 +
    if !auth::verify_password(&form.password, pw) {
133 +
        return Redirect::to("/login?error=Invalid+password").into_response();
134 +
    }
135 +
    let token = auth::generate_session_token();
136 +
    if let Err(e) = auth::create_session(&state.db, &token) {
137 +
        tracing::error!("create session: {e}");
138 +
        return Redirect::to("/login?error=Session+error").into_response();
139 +
    }
140 +
    let _ = prune_expired_sessions(&state.db);
141 +
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
142 +
    let mut resp = Redirect::to("/admin").into_response();
143 +
    resp.headers_mut()
144 +
        .insert(header::SET_COOKIE, cookie.parse().unwrap());
145 +
    resp
146 +
}
147 +
148 +
async fn logout_handler(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
149 +
    if let Some(token) = auth::extract_session_cookie(&headers) {
150 +
        auth::delete_session(&state.db, &token);
151 +
    }
152 +
    let mut resp = Redirect::to("/login").into_response();
153 +
    resp.headers_mut()
154 +
        .insert(header::SET_COOKIE, auth::clear_session_cookie().parse().unwrap());
155 +
    resp
156 +
}
157 +
158 +
// ── Admin ────────────────────────────────────────────────────────────────
159 +
160 +
async fn admin_handler(
161 +
    _session: auth::AuthSession,
162 +
    State(state): State<Arc<AppState>>,
163 +
    Query(q): Query<FlashQuery>,
164 +
) -> Response {
165 +
    let categories = db::list_categories(&state.db).unwrap_or_default();
166 +
    let raw_links = db::list_links(&state.db).unwrap_or_default();
167 +
    let links = raw_links
168 +
        .into_iter()
169 +
        .map(|l| {
170 +
            let cat = categories
171 +
                .iter()
172 +
                .find(|c| c.id == l.category_id)
173 +
                .map(|c| c.name.clone())
174 +
                .unwrap_or_default();
175 +
            AdminLinkRow {
176 +
                short_id: l.short_id,
177 +
                title: l.title,
178 +
                url: l.url,
179 +
                category: cat,
180 +
            }
181 +
        })
182 +
        .collect();
183 +
    render(AdminTemplate {
184 +
        success: q.success,
185 +
        error: q.error,
186 +
        categories,
187 +
        links,
188 +
    })
189 +
}
190 +
191 +
#[derive(Deserialize)]
192 +
struct AddCategoryForm {
193 +
    name: String,
194 +
}
195 +
196 +
async fn admin_add_category(
197 +
    _session: auth::AuthSession,
198 +
    State(state): State<Arc<AppState>>,
199 +
    Form(form): Form<AddCategoryForm>,
200 +
) -> Response {
201 +
    let name = form.name.trim();
202 +
    if name.is_empty() {
203 +
        return Redirect::to("/admin?error=Name+required").into_response();
204 +
    }
205 +
    match db::create_category(&state.db, name) {
206 +
        Ok(_) => Redirect::to("/admin?success=Category+added").into_response(),
207 +
        Err(e) => {
208 +
            tracing::error!("create category: {e}");
209 +
            Redirect::to("/admin?error=Failed+to+add+category").into_response()
210 +
        }
211 +
    }
212 +
}
213 +
214 +
async fn admin_delete_category(
215 +
    _session: auth::AuthSession,
216 +
    State(state): State<Arc<AppState>>,
217 +
    Path(short_id): Path<String>,
218 +
) -> Response {
219 +
    let _ = db::delete_category_by_short_id(&state.db, &short_id);
220 +
    Redirect::to("/admin?success=Category+removed").into_response()
221 +
}
222 +
223 +
#[derive(Deserialize)]
224 +
struct AddLinkForm {
225 +
    title: String,
226 +
    url: String,
227 +
    category: String,
228 +
}
229 +
230 +
async fn admin_add_link(
231 +
    _session: auth::AuthSession,
232 +
    State(state): State<Arc<AppState>>,
233 +
    Form(form): Form<AddLinkForm>,
234 +
) -> Response {
235 +
    let title = form.title.trim();
236 +
    let url = form.url.trim();
237 +
    if title.is_empty() || url.is_empty() {
238 +
        return Redirect::to("/admin?error=Title+and+URL+required").into_response();
239 +
    }
240 +
    let cat = match db::get_category_by_name(&state.db, form.category.trim()) {
241 +
        Ok(Some(c)) => c,
242 +
        Ok(None) => return Redirect::to("/admin?error=Unknown+category").into_response(),
243 +
        Err(e) => {
244 +
            tracing::error!("get category: {e}");
245 +
            return Redirect::to("/admin?error=Server+error").into_response();
246 +
        }
247 +
    };
248 +
    match db::create_link(&state.db, title, url, cat.id) {
249 +
        Ok(_) => Redirect::to("/admin?success=Link+added").into_response(),
250 +
        Err(e) => {
251 +
            tracing::error!("create link: {e}");
252 +
            Redirect::to("/admin?error=Failed+to+add+link").into_response()
253 +
        }
254 +
    }
255 +
}
256 +
257 +
async fn admin_delete_link(
258 +
    _session: auth::AuthSession,
259 +
    State(state): State<Arc<AppState>>,
260 +
    Path(short_id): Path<String>,
261 +
) -> Response {
262 +
    let _ = db::delete_link_by_short_id(&state.db, &short_id);
263 +
    Redirect::to("/admin?success=Link+removed").into_response()
264 +
}
265 +
266 +
// ── JSON API ─────────────────────────────────────────────────────────────
267 +
268 +
async fn api_list_categories(State(state): State<Arc<AppState>>) -> Response {
269 +
    match db::list_categories(&state.db) {
270 +
        Ok(cats) => Json(cats).into_response(),
271 +
        Err(e) => {
272 +
            tracing::error!("list categories: {e}");
273 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
274 +
        }
275 +
    }
276 +
}
277 +
278 +
#[derive(Deserialize)]
279 +
struct ListLinksQuery {
280 +
    category: Option<String>,
281 +
}
282 +
283 +
async fn api_list_links(
284 +
    State(state): State<Arc<AppState>>,
285 +
    Query(q): Query<ListLinksQuery>,
286 +
) -> Response {
287 +
    let categories = match db::list_categories(&state.db) {
288 +
        Ok(c) => c,
289 +
        Err(e) => {
290 +
            tracing::error!("list categories: {e}");
291 +
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
292 +
        }
293 +
    };
294 +
    let links = match db::list_links(&state.db) {
295 +
        Ok(l) => l,
296 +
        Err(e) => {
297 +
            tracing::error!("list links: {e}");
298 +
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
299 +
        }
300 +
    };
301 +
    if let Some(name) = q.category.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
302 +
        let Some(cat) = categories.iter().find(|c| c.name.eq_ignore_ascii_case(name)) else {
303 +
            return (
304 +
                StatusCode::NOT_FOUND,
305 +
                Json(serde_json::json!({ "error": "unknown category" })),
306 +
            )
307 +
                .into_response();
308 +
        };
309 +
        let filtered: Vec<&Link> = links.iter().filter(|l| l.category_id == cat.id).collect();
310 +
        return Json(filtered).into_response();
311 +
    }
312 +
    let mut grouped = serde_json::Map::new();
313 +
    for cat in &categories {
314 +
        let items: Vec<&Link> = links.iter().filter(|l| l.category_id == cat.id).collect();
315 +
        grouped.insert(cat.name.clone(), serde_json::to_value(items).unwrap());
316 +
    }
317 +
    Json(serde_json::Value::Object(grouped)).into_response()
318 +
}
319 +
320 +
#[derive(Deserialize)]
321 +
struct ApiCreateLink {
322 +
    category: String,
323 +
    title: String,
324 +
    url: String,
325 +
}
326 +
327 +
async fn api_create_link(
328 +
    State(state): State<Arc<AppState>>,
329 +
    Json(body): Json<ApiCreateLink>,
330 +
) -> Response {
331 +
    let title = body.title.trim();
332 +
    let url = body.url.trim();
333 +
    if title.is_empty() || url.is_empty() {
334 +
        return (
335 +
            StatusCode::BAD_REQUEST,
336 +
            Json(serde_json::json!({ "error": "title and url required" })),
337 +
        )
338 +
            .into_response();
339 +
    }
340 +
    let cat = match db::get_category_by_name(&state.db, body.category.trim()) {
341 +
        Ok(Some(c)) => c,
342 +
        Ok(None) => {
343 +
            return (
344 +
                StatusCode::NOT_FOUND,
345 +
                Json(serde_json::json!({ "error": "unknown category" })),
346 +
            )
347 +
                .into_response();
348 +
        }
349 +
        Err(e) => {
350 +
            tracing::error!("get category: {e}");
351 +
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
352 +
        }
353 +
    };
354 +
    match db::create_link(&state.db, title, url, cat.id) {
355 +
        Ok(link) => (StatusCode::CREATED, Json(link)).into_response(),
356 +
        Err(e) => {
357 +
            tracing::error!("create link: {e}");
358 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
359 +
        }
360 +
    }
361 +
}
362 +
363 +
async fn require_api_key(
364 +
    State(state): State<Arc<AppState>>,
365 +
    headers: HeaderMap,
366 +
    request: Request,
367 +
    next: Next,
368 +
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
369 +
    let server_key = state.api_key.as_deref().ok_or((
370 +
        StatusCode::FORBIDDEN,
371 +
        Json(serde_json::json!({ "error": "API key not configured" })),
372 +
    ))?;
373 +
    let provided = headers.get("x-api-key").and_then(|v| v.to_str().ok());
374 +
    if let Some(k) = provided {
375 +
        if auth::verify_api_key(k, server_key) {
376 +
            return Ok(next.run(request).await);
377 +
        }
378 +
    }
379 +
    Err((
380 +
        StatusCode::UNAUTHORIZED,
381 +
        Json(serde_json::json!({ "error": "Invalid or missing API key" })),
382 +
    ))
383 +
}
384 +
385 +
// ── main ─────────────────────────────────────────────────────────────────
386 +
387 +
#[tokio::main]
388 +
async fn main() {
389 +
    dotenvy::dotenv().ok();
390 +
    tracing_subscriber::fmt()
391 +
        .with_env_filter(
392 +
            tracing_subscriber::EnvFilter::try_from_default_env()
393 +
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,bookmarks=info")),
394 +
        )
395 +
        .init();
396 +
397 +
    let db_path =
398 +
        std::env::var("BOOKMARKS_DB_PATH").unwrap_or_else(|_| "bookmarks.sqlite".to_string());
399 +
    let conn = Connection::open(&db_path).expect("open sqlite");
400 +
    conn.execute_batch("PRAGMA foreign_keys = ON;")
401 +
        .expect("enable foreign keys");
402 +
    conn.execute_batch(SESSION_SCHEMA).expect("session schema");
403 +
    conn.execute_batch(db::SCHEMA).expect("bookmarks schema");
404 +
    let db: Db = Arc::new(Mutex::new(conn));
405 +
406 +
    let cookie_secure = std::env::var("COOKIE_SECURE")
407 +
        .map(|v| v.eq_ignore_ascii_case("true"))
408 +
        .unwrap_or(false);
409 +
410 +
    let state = Arc::new(AppState {
411 +
        db,
412 +
        admin_password: std::env::var("BOOKMARKS_PASSWORD").ok().filter(|s| !s.is_empty()),
413 +
        api_key: std::env::var("BOOKMARKS_API_KEY").ok().filter(|s| !s.is_empty()),
414 +
        cookie_secure,
415 +
    });
416 +
417 +
    let api_authed = Router::new()
418 +
        .route("/api/links", post(api_create_link))
419 +
        .route_layer(middleware::from_fn_with_state(state.clone(), require_api_key));
420 +
421 +
    let api_open = Router::new()
422 +
        .route("/api/categories", get(api_list_categories))
423 +
        .route("/api/links", get(api_list_links));
424 +
425 +
    let app = Router::new()
426 +
        .route("/", get(index_handler))
427 +
        .route("/login", get(login_get).post(login_post))
428 +
        .route("/logout", get(logout_handler))
429 +
        .route("/admin", get(admin_handler))
430 +
        .route("/admin/categories", post(admin_add_category))
431 +
        .route("/admin/categories/{short_id}/delete", post(admin_delete_category))
432 +
        .route("/admin/links", post(admin_add_link))
433 +
        .route("/admin/links/{short_id}/delete", post(admin_delete_link))
434 +
        .route("/static/{*path}", get(static_handler))
435 +
        .merge(api_authed)
436 +
        .merge(api_open)
437 +
        .merge(andromeda_darkmatter_css::router::<Arc<AppState>>())
438 +
        .with_state(state);
439 +
440 +
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
441 +
    let port: u16 = std::env::var("PORT")
442 +
        .ok()
443 +
        .and_then(|v| v.parse().ok())
444 +
        .unwrap_or(3000);
445 +
    let addr = format!("{host}:{port}");
446 +
    let listener = tokio::net::TcpListener::bind(&addr)
447 +
        .await
448 +
        .unwrap_or_else(|_| panic!("Failed to bind to {addr}"));
449 +
450 +
    tracing::info!("Bookmarks server running on http://{host}:{port}");
451 +
    axum::serve(listener, app).await.unwrap();
452 +
}
apps/bookmarks/src/templates/admin.html (added) +123 −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="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 +
    <link rel="manifest" href="/static/site.webmanifest" />
13 +
    <title>Bookmarks | Admin</title>
14 +
    <style>
15 +
      .section-label {
16 +
        font-size: 14px;
17 +
        font-weight: 400;
18 +
        opacity: 0.5;
19 +
        margin: 0 0 0.5rem;
20 +
      }
21 +
      section { width: 100%; margin-top: 1.5rem; }
22 +
    </style>
23 +
  </head>
24 +
  <body>
25 +
    <div class="header">
26 +
      <a href="/" class="logo">BOOKMARKS</a>
27 +
      <nav class="links">
28 +
        <a href="/logout">logout</a>
29 +
      </nav>
30 +
    </div>
31 +
32 +
    {% if let Some(msg) = success %}
33 +
    <p class="success">{{ msg }}</p>
34 +
    {% endif %}
35 +
    {% if let Some(err) = error %}
36 +
    <p class="error">{{ err }}</p>
37 +
    {% endif %}
38 +
39 +
    <section>
40 +
      <h3 class="section-label">Categories</h3>
41 +
      <form class="form" method="POST" action="/admin/categories">
42 +
        <div class="form-row">
43 +
          <div class="form-field">
44 +
            <input type="text" name="name" placeholder="new category" required />
45 +
          </div>
46 +
          <button type="submit">Add</button>
47 +
        </div>
48 +
      </form>
49 +
      {% if categories.is_empty() %}
50 +
      <p class="empty">No categories yet.</p>
51 +
      {% else %}
52 +
      <ul class="admin-list">
53 +
        {% for cat in categories %}
54 +
        <li class="admin-list-item">
55 +
          <div class="admin-list-info">
56 +
            <span class="admin-list-title">{{ cat.name }}</span>
57 +
          </div>
58 +
          <div class="admin-list-actions">
59 +
            <form method="POST" action="/admin/categories/{{ cat.short_id }}/delete" class="inline-form">
60 +
              <button type="submit" class="link-button danger">delete</button>
61 +
            </form>
62 +
          </div>
63 +
        </li>
64 +
        {% endfor %}
65 +
      </ul>
66 +
      {% endif %}
67 +
    </section>
68 +
69 +
    <section>
70 +
      <h3 class="section-label">Add Link</h3>
71 +
      {% if categories.is_empty() %}
72 +
      <p class="empty">Add a category first.</p>
73 +
      {% else %}
74 +
      <form class="form" method="POST" action="/admin/links">
75 +
        <div class="form-field">
76 +
          <label for="title">Title</label>
77 +
          <input type="text" id="title" name="title" required />
78 +
        </div>
79 +
        <div class="form-field">
80 +
          <label for="url">URL</label>
81 +
          <input type="url" id="url" name="url" required />
82 +
        </div>
83 +
        <div class="form-field">
84 +
          <label for="category">Category</label>
85 +
          <select id="category" name="category" required>
86 +
            {% for cat in categories %}
87 +
            <option value="{{ cat.name }}">{{ cat.name }}</option>
88 +
            {% endfor %}
89 +
          </select>
90 +
        </div>
91 +
        <div class="form-actions">
92 +
          <button type="submit">Add link</button>
93 +
        </div>
94 +
      </form>
95 +
      {% endif %}
96 +
    </section>
97 +
98 +
    <section>
99 +
      <h3 class="section-label">Links</h3>
100 +
      {% if links.is_empty() %}
101 +
      <p class="empty">No links yet.</p>
102 +
      {% else %}
103 +
      <ul class="admin-list">
104 +
        {% for link in links %}
105 +
        <li class="admin-list-item">
106 +
          <div class="admin-list-info">
107 +
            <a class="admin-list-title" href="{{ link.url }}" target="_blank" rel="noopener noreferrer">{{ link.title }}</a>
108 +
            <div class="admin-list-meta">
109 +
              <span class="tag">{{ link.category }}</span>
110 +
            </div>
111 +
          </div>
112 +
          <div class="admin-list-actions">
113 +
            <form method="POST" action="/admin/links/{{ link.short_id }}/delete" class="inline-form">
114 +
              <button type="submit" class="link-button danger">delete</button>
115 +
            </form>
116 +
          </div>
117 +
        </li>
118 +
        {% endfor %}
119 +
      </ul>
120 +
      {% endif %}
121 +
    </section>
122 +
  </body>
123 +
</html>
apps/bookmarks/src/templates/index.html (added) +54 −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="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 +
    <link rel="manifest" href="/static/site.webmanifest" />
13 +
    <title>Bookmarks</title>
14 +
    <style>
15 +
      .category-heading {
16 +
        font-size: 14px;
17 +
        font-weight: 400;
18 +
        opacity: 0.5;
19 +
        text-transform: uppercase;
20 +
        letter-spacing: 0.05em;
21 +
      }
22 +
    </style>
23 +
  </head>
24 +
  <body>
25 +
    <div class="header">
26 +
      <a href="/" class="logo">BOOKMARKS</a>
27 +
      <nav class="links">
28 +
        <a href="/admin">add</a>
29 +
      </nav>
30 +
    </div>
31 +
32 +
    {% if groups.is_empty() %}
33 +
    <p class="empty">No categories yet.</p>
34 +
    {% else %}
35 +
    {% for group in groups %}
36 +
    <section>
37 +
      <h2 class="category-heading">{{ group.name }}</h2>
38 +
      {% if group.links.is_empty() %}
39 +
      <p class="empty">No links.</p>
40 +
      {% else %}
41 +
      <ul class="item-list">
42 +
        {% for link in group.links %}
43 +
        <li class="item">
44 +
          <a class="item-title" href="{{ link.url }}" target="_blank" rel="noopener noreferrer">{{ link.title }}</a>
45 +
          <div class="item-meta">{{ link.url }}</div>
46 +
        </li>
47 +
        {% endfor %}
48 +
      </ul>
49 +
      {% endif %}
50 +
    </section>
51 +
    {% endfor %}
52 +
    {% endif %}
53 +
  </body>
54 +
</html>
apps/bookmarks/src/templates/login.html (added) +34 −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="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 +
    <link rel="manifest" href="/static/site.webmanifest" />
13 +
    <title>Bookmarks | Login</title>
14 +
  </head>
15 +
  <body>
16 +
    <div class="header">
17 +
      <a href="/" class="logo">BOOKMARKS</a>
18 +
    </div>
19 +
20 +
    {% if let Some(err) = error %}
21 +
    <p class="error">{{ err }}</p>
22 +
    {% endif %}
23 +
24 +
    <form class="form" method="POST" action="/login">
25 +
      <div class="form-field">
26 +
        <label for="password">Password</label>
27 +
        <input type="password" id="password" name="password" required autofocus />
28 +
      </div>
29 +
      <div class="form-actions">
30 +
        <button type="submit">Login</button>
31 +
      </div>
32 +
    </form>
33 +
  </body>
34 +
</html>
apps/bookmarks/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/bookmarks/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/bookmarks/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/bookmarks/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/bookmarks/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/bookmarks/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/bookmarks/static/icon.png (added) +0 −0

Binary file — no preview.

apps/bookmarks/static/og.png (added) +0 −0

Binary file — no preview.

apps/bookmarks/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/bookmarks/static/styles.css (added) +221 −0
1 +
/* feeds — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
/* The logo wraps an h1 in feeds markup. */
6 +
7 +
.logo h1 {
8 +
  font-size: 28px;
9 +
  font-weight: 700;
10 +
  text-transform: uppercase;
11 +
}
12 +
13 +
.about {
14 +
  display: flex;
15 +
  flex-direction: column;
16 +
  gap: 0.5rem;
17 +
  font-size: 14px;
18 +
  line-height: 1.25rem;
19 +
}
20 +
21 +
/* Feeds list */
22 +
23 +
.feeds-list {
24 +
  width: 100%;
25 +
  display: flex;
26 +
  flex-direction: column;
27 +
  gap: 1.5rem;
28 +
}
29 +
30 +
.feed-item {
31 +
  display: flex;
32 +
  flex-direction: column;
33 +
  gap: 0.5rem;
34 +
  padding: 1rem 0;
35 +
  border-bottom: 1px solid #333;
36 +
}
37 +
38 +
.feed-item:last-child {
39 +
  border-bottom: none;
40 +
}
41 +
42 +
.feed-meta {
43 +
  display: flex;
44 +
  justify-content: space-between;
45 +
  align-items: center;
46 +
  font-size: 12px;
47 +
  opacity: 0.5;
48 +
}
49 +
50 +
.feed-source {
51 +
  font-weight: 700;
52 +
}
53 +
54 +
.feed-title {
55 +
  font-size: 16px;
56 +
  font-weight: 400;
57 +
  line-height: 1.4;
58 +
}
59 +
60 +
.feed-title a {
61 +
  text-decoration: none;
62 +
}
63 +
64 +
.feed-author {
65 +
  font-size: 12px;
66 +
  opacity: 0.5;
67 +
  font-style: italic;
68 +
}
69 +
70 +
#feed-urls {
71 +
  font-size: 12px;
72 +
  opacity: 0.5;
73 +
}
74 +
75 +
.no-feeds,
76 +
#loading {
77 +
  text-align: center;
78 +
  opacity: 0.5;
79 +
  padding: 2rem;
80 +
}
81 +
82 +
#error {
83 +
  text-align: center;
84 +
  padding: 2rem;
85 +
}
86 +
87 +
/* Admin forms */
88 +
89 +
.admin-form {
90 +
  display: flex;
91 +
  flex-direction: column;
92 +
  gap: 0.75rem;
93 +
  width: 100%;
94 +
}
95 +
96 +
.admin-form h3 {
97 +
  font-size: 14px;
98 +
  font-weight: 400;
99 +
  opacity: 0.5;
100 +
}
101 +
102 +
.admin-notice,
103 +
.hint {
104 +
  font-size: 12px;
105 +
  opacity: 0.5;
106 +
  line-height: 1.4;
107 +
}
108 +
109 +
/* Discover panel */
110 +
111 +
.discover-row {
112 +
  display: flex;
113 +
  gap: 0.5rem;
114 +
  width: 100%;
115 +
}
116 +
117 +
.discover-row input {
118 +
  flex: 1;
119 +
}
120 +
121 +
.discover-status {
122 +
  font-size: 12px;
123 +
}
124 +
125 +
.discover-results {
126 +
  display: flex;
127 +
  flex-direction: column;
128 +
  gap: 0.25rem;
129 +
  width: 100%;
130 +
}
131 +
132 +
.discover-result-item {
133 +
  background: #121113;
134 +
  color: #ffffff;
135 +
  border: 1px solid #333;
136 +
  padding: 8px 10px;
137 +
  font-size: 12px;
138 +
  text-align: left;
139 +
  cursor: pointer;
140 +
  width: 100%;
141 +
  white-space: nowrap;
142 +
  overflow: hidden;
143 +
  text-overflow: ellipsis;
144 +
  opacity: 0.7;
145 +
  border-radius: 0;
146 +
  -webkit-appearance: none;
147 +
  appearance: none;
148 +
}
149 +
150 +
.discover-result-item:hover {
151 +
  border-color: #555;
152 +
  opacity: 1;
153 +
}
154 +
155 +
.discover-result-item.active {
156 +
  border-color: #ffffff;
157 +
  opacity: 1;
158 +
}
159 +
160 +
/* Admin subs */
161 +
162 +
.admin-subs {
163 +
  width: 100%;
164 +
  display: flex;
165 +
  flex-direction: column;
166 +
  gap: 1rem;
167 +
}
168 +
169 +
.admin-subs h3 {
170 +
  font-size: 14px;
171 +
  opacity: 0.5;
172 +
  font-weight: 400;
173 +
}
174 +
175 +
.feed-item form.inline {
176 +
  display: flex;
177 +
  gap: 0.5rem;
178 +
  align-items: center;
179 +
}
180 +
181 +
.feed-item form.inline input {
182 +
  flex: 1;
183 +
}
184 +
185 +
/* Generic .danger on buttons (used in admin) */
186 +
187 +
button.danger,
188 +
.btn.danger {
189 +
  opacity: 0.5;
190 +
}
191 +
192 +
button.danger:hover,
193 +
.btn.danger:hover {
194 +
  opacity: 0.3;
195 +
}
196 +
197 +
/* Category list (admin) */
198 +
199 +
.category-list {
200 +
  list-style: none;
201 +
  margin-left: 0;
202 +
}
203 +
204 +
.category-list li {
205 +
  display: flex;
206 +
  justify-content: space-between;
207 +
  align-items: center;
208 +
  padding: 0.25rem 0;
209 +
}
210 +
211 +
@media (max-width: 480px) {
212 +
  .feed-meta {
213 +
    flex-direction: column;
214 +
    align-items: flex-start;
215 +
    gap: 0.25rem;
216 +
  }
217 +
218 +
  .feed-title {
219 +
    font-size: 14px;
220 +
  }
221 +
}
dist-workspace.toml +1 −0
9 9
    "cargo:apps/cellar",
10 10
    "cargo:apps/posts",
11 11
    "cargo:apps/library",
12 +
    "cargo:apps/bookmarks",
12 13
]
13 14
14 15
# Config for 'dist'
docker-compose.yml +13 −0
77 77
      - library_data:/data
78 78
    env_file: apps/library/.env
79 79
80 +
  bookmarks:
81 +
    image: ghcr.io/stevedylandev/andromeda/bookmarks:latest
82 +
    restart: unless-stopped
83 +
    ports:
84 +
      - "3737:3000"
85 +
    volumes:
86 +
      - bookmarks_data:/data
87 +
    env_file: apps/bookmarks/.env
88 +
80 89
  backup:
81 90
    image: ghcr.io/stevedylandev/andromeda/backup:latest
82 91
    volumes:
84 93
      - sipp_data:/data/sipp:ro
85 94
      - cellar_data:/data/cellar:ro
86 95
      - library_data:/data/library:ro
96 +
      - bookmarks_data:/data/bookmarks:ro
87 97
    env_file: apps/backup/.env
88 98
    restart: unless-stopped
89 99
109 119
  library_data:
110 120
    external: true
111 121
    name: library_library-data
122 +
  bookmarks_data:
123 +
    external: true
124 +
    name: bookmarks_bookmarks-data
docs/docs/pages/apps/bookmarks.mdx (added) +74 −0
1 +
# Bookmarks
2 +
3 +
Bookmarks is a single-user link saver. Add links via the admin panel or JSON API, organize them into categories, and view them on a public index page grouped by category.
4 +
5 +
- Single Rust binary with embedded assets
6 +
- Local SQLite storage
7 +
- Password-protected admin panel for managing categories and links
8 +
- JSON read API (open) and write API (key-guarded)
9 +
- Dark themed UI with Commit Mono font
10 +
11 +
## Configure
12 +
13 +
### Environment Variables
14 +
15 +
| Variable | Description | Default |
16 +
|---|---|---|
17 +
| `BOOKMARKS_PASSWORD` | Password for the admin panel | -- |
18 +
| `BOOKMARKS_API_KEY` | API key for `POST /api/links` (omit to disable write API) | -- |
19 +
| `BOOKMARKS_DB_PATH` | SQLite database path | `bookmarks.sqlite` |
20 +
| `HOST` | Bind address | `127.0.0.1` |
21 +
| `PORT` | Bind port | `3000` |
22 +
| `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` |
23 +
24 +
`BOOKMARKS_PASSWORD` is required to access the admin panel. `BOOKMARKS_API_KEY` is only needed if you want to create links from outside the browser.
25 +
26 +
## Deploy
27 +
28 +
### Docker
29 +
30 +
```bash
31 +
cd apps/bookmarks
32 +
cp .env.example .env
33 +
# Edit .env with your credentials
34 +
docker compose up -d
35 +
```
36 +
37 +
### Binary
38 +
39 +
```bash
40 +
cargo build --release -p bookmarks
41 +
```
42 +
43 +
The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly.
44 +
45 +
## Use
46 +
47 +
### Admin Panel
48 +
49 +
Set `BOOKMARKS_PASSWORD` and visit `/login`. From the admin panel you can:
50 +
51 +
- Create and remove categories
52 +
- Add links with title, URL, and category
53 +
- Remove links
54 +
55 +
The public index at `/` shows all links grouped by category.
56 +
57 +
### JSON API
58 +
59 +
Read endpoints are open. Write endpoints require `x-api-key: <BOOKMARKS_API_KEY>`.
60 +
61 +
| Method | Path | Auth | Purpose |
62 +
|---|---|---|---|
63 +
| `GET` | `/api/categories` | open | List categories |
64 +
| `GET` | `/api/links` | open | List links grouped by category. Query: `category` to filter by name |
65 +
| `POST` | `/api/links` | api key | Create link. Body: `{category, title, url}` |
66 +
67 +
Example:
68 +
69 +
```bash
70 +
curl -X POST http://localhost:3000/api/links \
71 +
  -H "x-api-key: $BOOKMARKS_API_KEY" \
72 +
  -H "content-type: application/json" \
73 +
  -d '{"category":"Reading","title":"Example","url":"https://example.com"}'
74 +
```
docs/vocs.config.ts +4 −0
41 41
      text: 'Apps',
42 42
      items: [
43 43
        {
44 +
          text: 'Bookmarks',
45 +
          link: '/apps/bookmarks',
46 +
        },
47 +
        {
44 48
          text: 'Cellar',
45 49
          link: '/apps/cellar',
46 50
        },