feat: init bookmarks
21f89356
27 file(s) · +1205 −4
| 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 |
|
| 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 |
|
| 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" |
| 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", |
| 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 |
| 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" |
| 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"] |
| 1 | + | [general] |
|
| 2 | + | dirs = ["src/templates"] |
| 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: |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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> |
| 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> |
| 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> |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 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"} |
| 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 | + | } |
| 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' |
| 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 |
|