Merge pull request #54 from stevedylandev/feat/quotes
2bf08b36
Add Quotes App
24 file(s) · +1154 −4
Add Quotes App
| 4 | 4 | .gitignore |
|
| 5 | 5 | *.md |
|
| 6 | 6 | LICENSE |
|
| 7 | + | ||
| 8 | + | # App databases and bulk seed data must never enter an image build context. |
|
| 9 | + | **/*.sqlite |
|
| 10 | + | **/*.sqlite-journal |
|
| 11 | + | **/*.sqlite-wal |
|
| 12 | + | **/*.sqlite-shm |
|
| 13 | + | apps/quotes/quotes.csv |
| 19 | 19 | - name: Determine which apps to build |
|
| 20 | 20 | id: filter |
|
| 21 | 21 | run: | |
|
| 22 | - | ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","shrink","sipp"]' |
|
| 22 | + | ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","quotes","shrink","sipp"]' |
|
| 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 backup blobs bookmarks cellar easel feeds jotts kepler library og posts shrink sipp; do |
|
| 32 | + | for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts quotes shrink sipp; 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='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","shrink","sipp"]' |
|
| 28 | + | ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","quotes","shrink","sipp"]' |
|
| 29 | 29 | ||
| 30 | 30 | # Tags: per-app (app/version) or bare (version) |
|
| 31 | 31 | if [[ "${GITHUB_REF}" == refs/tags/* ]]; then |
|
| 52 | 52 | fi |
|
| 53 | 53 | ||
| 54 | 54 | apps=() |
|
| 55 | - | for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts shrink sipp; do |
|
| 55 | + | for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts quotes shrink sipp; do |
|
| 56 | 56 | if echo "$changed" | grep -q "^apps/${app}/"; then |
|
| 57 | 57 | apps+=("\"${app}\"") |
|
| 58 | 58 | fi |
|
| 30 | 30 | apps/shrink/shrink |
|
| 31 | 31 | apps/sipp/sipp |
|
| 32 | 32 | apps/kepler/kepler |
|
| 33 | + | apps/quotes/quotes |
|
| 34 | + | apps/quotes/quotes.csv |
| 1 | + | QUOTES_PASSWORD=changeme |
|
| 2 | + | QUOTES_API_KEY= |
|
| 3 | + | QUOTES_DB_PATH=quotes.sqlite |
|
| 4 | + | COOKIE_SECURE=false |
|
| 5 | + | BASE_URL=http://localhost:3000 |
|
| 6 | + | HOST=127.0.0.1 |
|
| 7 | + | PORT=3000 |
| 1 | + | # Build from repo root: docker build -t quotes -f apps/quotes/Dockerfile . |
|
| 2 | + | FROM golang:1.24-bookworm AS builder |
|
| 3 | + | WORKDIR /app |
|
| 4 | + | COPY pkg/ ./pkg/ |
|
| 5 | + | COPY apps/quotes/go.mod apps/quotes/go.sum ./apps/quotes/ |
|
| 6 | + | WORKDIR /app/apps/quotes |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/quotes/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /quotes . |
|
| 10 | + | ||
| 11 | + | FROM debian:bookworm-slim |
|
| 12 | + | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 13 | + | COPY --from=builder /quotes /usr/local/bin/quotes |
|
| 14 | + | WORKDIR /data |
|
| 15 | + | ENV HOST=0.0.0.0 |
|
| 16 | + | ENV PORT=3000 |
|
| 17 | + | EXPOSE 3000 |
|
| 18 | + | CMD ["quotes"] |
| 1 | + | # quotes |
|
| 2 | + | ||
| 3 | + | A minimal quote-a-day site. The landing page shows a single **quote of the day** |
|
| 4 | + | from classic literature — white, centered, on a dark background. `/admin` is a |
|
| 5 | + | session-protected page to add and manage quotes, matching the other andromeda |
|
| 6 | + | apps. |
|
| 7 | + | ||
| 8 | + | ## Run |
|
| 9 | + | ||
| 10 | + | ```sh |
|
| 11 | + | cp .env.example .env # set QUOTES_PASSWORD |
|
| 12 | + | go run . |
|
| 13 | + | ``` |
|
| 14 | + | ||
| 15 | + | Landing: `http://localhost:3000/` — the quote of the day (deterministic, rotates |
|
| 16 | + | at UTC midnight). |
|
| 17 | + | Admin: `http://localhost:3000/admin` — log in with `QUOTES_PASSWORD`. |
|
| 18 | + | ||
| 19 | + | ## Seeding from a CSV |
|
| 20 | + | ||
| 21 | + | `quotes.csv` is a Goodreads-style export (`quote,author,category`). The seed |
|
| 22 | + | command imports only classic-literature quotes, matched case-sensitively against |
|
| 23 | + | the author/title list in `classic_authors.txt`: |
|
| 24 | + | ||
| 25 | + | ```sh |
|
| 26 | + | go run . seed quotes.csv |
|
| 27 | + | ``` |
|
| 28 | + | ||
| 29 | + | - Edit `classic_authors.txt` (one author or book title per line, `#` comments |
|
| 30 | + | allowed) to widen or narrow the selection, then re-run the command. |
|
| 31 | + | - Attribution is split at the first comma: `author` and `source` (book title). |
|
| 32 | + | - Re-seeding is idempotent — quotes already present (matched on text + author) |
|
| 33 | + | are skipped. |
|
| 34 | + | - The author list is also **embedded in the binary**, so `seed` works even when |
|
| 35 | + | `classic_authors.txt` is not on disk (e.g. inside a container). An on-disk |
|
| 36 | + | file always takes precedence, so local edits apply without a rebuild. |
|
| 37 | + | ||
| 38 | + | ### Seeding a Docker volume |
|
| 39 | + | ||
| 40 | + | The image only ships the binary — the DB lives on the `quotes_data` volume |
|
| 41 | + | (`QUOTES_DB_PATH=/data/quotes.sqlite`), and the 138 MB `quotes.csv` is |
|
| 42 | + | deliberately excluded from the build (see `.dockerignore`). So seeding is a |
|
| 43 | + | one-off `run` that bind-mounts the CSV and writes into the same named volume the |
|
| 44 | + | service uses: |
|
| 45 | + | ||
| 46 | + | ```sh |
|
| 47 | + | # From the repo root (uses the `quotes` service's volume + env) |
|
| 48 | + | docker compose run --rm \ |
|
| 49 | + | -v "$PWD/apps/quotes/quotes.csv:/seed/quotes.csv:ro" \ |
|
| 50 | + | quotes quotes seed /seed/quotes.csv |
|
| 51 | + | ||
| 52 | + | # Then start the service normally |
|
| 53 | + | docker compose up -d quotes |
|
| 54 | + | ``` |
|
| 55 | + | ||
| 56 | + | The author list comes from the embedded copy. To seed with a different list |
|
| 57 | + | without rebuilding the image, mount your edited file over the working directory |
|
| 58 | + | (`/data`) too: |
|
| 59 | + | ||
| 60 | + | ```sh |
|
| 61 | + | docker compose run --rm \ |
|
| 62 | + | -v "$PWD/apps/quotes/quotes.csv:/seed/quotes.csv:ro" \ |
|
| 63 | + | -v "$PWD/apps/quotes/classic_authors.txt:/data/classic_authors.txt:ro" \ |
|
| 64 | + | quotes quotes seed /seed/quotes.csv |
|
| 65 | + | ``` |
|
| 66 | + | ||
| 67 | + | Because seeding is idempotent, you can re-run either command after widening the |
|
| 68 | + | list to pull in the newly matched quotes. |
|
| 69 | + | ||
| 70 | + | ## API (public, read-only) |
|
| 71 | + | ||
| 72 | + | - `GET /api/quotes?limit=100` — most recent quotes |
|
| 73 | + | - `GET /api/quotes/today` — the quote of the day |
|
| 74 | + | - `GET /api/quotes/{short_id}` — a single quote |
|
| 75 | + | ||
| 76 | + | ## Environment |
|
| 77 | + | ||
| 78 | + | | Var | Default | Notes | |
|
| 79 | + | | --- | --- | --- | |
|
| 80 | + | | `QUOTES_PASSWORD` | _(empty)_ | Admin login password (plaintext or bcrypt hash). Empty disables login. | |
|
| 81 | + | | `QUOTES_API_KEY` | _(empty)_ | Reserved; the read API is currently public. | |
|
| 82 | + | | `QUOTES_DB_PATH` | `quotes.sqlite` | SQLite file path. | |
|
| 83 | + | | `HOST` / `PORT` | `0.0.0.0` / `3000` | Listen address. | |
|
| 84 | + | | `BASE_URL` | `http://localhost:3000` | Used in social meta tags. | |
|
| 85 | + | | `COOKIE_SECURE` | `false` | Set `true` behind HTTPS. | |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "database/sql" |
|
| 5 | + | "embed" |
|
| 6 | + | "html/template" |
|
| 7 | + | "log/slog" |
|
| 8 | + | ||
| 9 | + | "github.com/stevedylandev/andromeda/pkg/auth" |
|
| 10 | + | ) |
|
| 11 | + | ||
| 12 | + | //go:embed templates/*.html static/* |
|
| 13 | + | var appFS embed.FS |
|
| 14 | + | ||
| 15 | + | type App struct { |
|
| 16 | + | DB *sql.DB |
|
| 17 | + | Log *slog.Logger |
|
| 18 | + | Templates *template.Template |
|
| 19 | + | Sessions *auth.Store |
|
| 20 | + | AdminPassword string |
|
| 21 | + | APIKey string |
|
| 22 | + | CookieSecure bool |
|
| 23 | + | BaseURL string |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | type quoteView struct { |
|
| 27 | + | Text string |
|
| 28 | + | Author string |
|
| 29 | + | Source string |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | type indexPageData struct { |
|
| 33 | + | BaseURL string |
|
| 34 | + | Quote *quoteView |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | type loginPageData struct { |
|
| 38 | + | Error string |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | type adminQuoteRow struct { |
|
| 42 | + | ShortID string |
|
| 43 | + | Text string |
|
| 44 | + | Author string |
|
| 45 | + | Source string |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | type adminPageData struct { |
|
| 49 | + | Success string |
|
| 50 | + | Error string |
|
| 51 | + | Total int |
|
| 52 | + | Quotes []adminQuoteRow |
|
| 53 | + | Query string |
|
| 54 | + | Searched bool |
|
| 55 | + | } |
| 1 | + | # Classic literature match list for the `quotes seed` command. |
|
| 2 | + | # |
|
| 3 | + | # Each non-empty, non-comment line is matched as a CASE-SENSITIVE substring |
|
| 4 | + | # against the CSV "author" column. So "Jane Austen" matches both |
|
| 5 | + | # "Jane Austen" and "Jane Austen, Pride and Prejudice". |
|
| 6 | + | # |
|
| 7 | + | # Edit this file (add/remove names or book titles) and re-run |
|
| 8 | + | # go run . seed quotes.csv |
|
| 9 | + | # to pull in more quotes. Re-seeding is idempotent (dedup on text + author). |
|
| 10 | + | ||
| 11 | + | Jane Austen |
|
| 12 | + | Charles Dickens |
|
| 13 | + | Leo Tolstoy |
|
| 14 | + | Dostoyevsky |
|
| 15 | + | Fyodor Dostoevsky |
|
| 16 | + | Homer |
|
| 17 | + | Herman Melville |
|
| 18 | + | Nathaniel Hawthorne |
|
| 19 | + | Mary Shelley |
|
| 20 | + | Bram Stoker |
|
| 21 | + | Emily Brontë |
|
| 22 | + | Charlotte Brontë |
|
| 23 | + | Anne Brontë |
|
| 24 | + | Victor Hugo |
|
| 25 | + | Alexandre Dumas |
|
| 26 | + | Franz Kafka |
|
| 27 | + | Marcel Proust |
|
| 28 | + | James Joyce |
|
| 29 | + | Virginia Woolf |
|
| 30 | + | F. Scott Fitzgerald |
|
| 31 | + | Ernest Hemingway |
|
| 32 | + | John Steinbeck |
|
| 33 | + | George Orwell |
|
| 34 | + | Jules Verne |
|
| 35 | + | H.G. Wells |
|
| 36 | + | Edgar Allan Poe |
|
| 37 | + | Walt Whitman |
|
| 38 | + | Emily Dickinson |
|
| 39 | + | Oscar Wilde |
|
| 40 | + | Mark Twain |
|
| 41 | + | Henry David Thoreau |
|
| 42 | + | George Eliot |
|
| 43 | + | Rudyard Kipling |
|
| 44 | + | Voltaire |
|
| 45 | + | Geoffrey Chaucer |
|
| 46 | + | Plato |
|
| 47 | + | Aristotle |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "database/sql" |
|
| 5 | + | "errors" |
|
| 6 | + | "strings" |
|
| 7 | + | "time" |
|
| 8 | + | ||
| 9 | + | "github.com/stevedylandev/andromeda/pkg/auth" |
|
| 10 | + | ) |
|
| 11 | + | ||
| 12 | + | const quotesSchema = ` |
|
| 13 | + | CREATE TABLE IF NOT EXISTS quotes ( |
|
| 14 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 15 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 16 | + | text TEXT NOT NULL, |
|
| 17 | + | author TEXT NOT NULL, |
|
| 18 | + | source TEXT, |
|
| 19 | + | added_at INTEGER NOT NULL, |
|
| 20 | + | updated_at INTEGER NOT NULL |
|
| 21 | + | ); |
|
| 22 | + | CREATE INDEX IF NOT EXISTS idx_quotes_added ON quotes(added_at DESC); |
|
| 23 | + | ` |
|
| 24 | + | ||
| 25 | + | type Quote struct { |
|
| 26 | + | ID int64 `json:"id"` |
|
| 27 | + | ShortID string `json:"short_id"` |
|
| 28 | + | Text string `json:"text"` |
|
| 29 | + | Author string `json:"author"` |
|
| 30 | + | Source *string `json:"source,omitempty"` |
|
| 31 | + | AddedAt int64 `json:"added_at"` |
|
| 32 | + | UpdatedAt int64 `json:"updated_at"` |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | const selectCols = `id, short_id, text, author, source, added_at, updated_at` |
|
| 36 | + | ||
| 37 | + | func scanQuote(s interface{ Scan(...any) error }) (*Quote, error) { |
|
| 38 | + | var q Quote |
|
| 39 | + | var source sql.NullString |
|
| 40 | + | err := s.Scan(&q.ID, &q.ShortID, &q.Text, &q.Author, &source, &q.AddedAt, &q.UpdatedAt) |
|
| 41 | + | if errors.Is(err, sql.ErrNoRows) { |
|
| 42 | + | return nil, nil |
|
| 43 | + | } |
|
| 44 | + | if err != nil { |
|
| 45 | + | return nil, err |
|
| 46 | + | } |
|
| 47 | + | if source.Valid { |
|
| 48 | + | v := source.String |
|
| 49 | + | q.Source = &v |
|
| 50 | + | } |
|
| 51 | + | return &q, nil |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | func countQuotes(db *sql.DB) (int, error) { |
|
| 55 | + | var n int |
|
| 56 | + | err := db.QueryRow(`SELECT COUNT(*) FROM quotes`).Scan(&n) |
|
| 57 | + | return n, err |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | func listQuotes(db *sql.DB, limit int) ([]Quote, error) { |
|
| 61 | + | rows, err := db.Query(`SELECT `+selectCols+` FROM quotes ORDER BY added_at DESC, id DESC LIMIT ?`, limit) |
|
| 62 | + | if err != nil { |
|
| 63 | + | return nil, err |
|
| 64 | + | } |
|
| 65 | + | defer rows.Close() |
|
| 66 | + | var out []Quote |
|
| 67 | + | for rows.Next() { |
|
| 68 | + | q, err := scanQuote(rows) |
|
| 69 | + | if err != nil { |
|
| 70 | + | return nil, err |
|
| 71 | + | } |
|
| 72 | + | out = append(out, *q) |
|
| 73 | + | } |
|
| 74 | + | return out, rows.Err() |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | func getQuoteByShortID(db *sql.DB, shortID string) (*Quote, error) { |
|
| 78 | + | return scanQuote(db.QueryRow(`SELECT `+selectCols+` FROM quotes WHERE short_id = ?`, shortID)) |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | // quoteOfTheDay returns a deterministic quote that is stable for the whole UTC |
|
| 82 | + | // day and rotates at midnight. Returns (nil, nil) when the table is empty. |
|
| 83 | + | func quoteOfTheDay(db *sql.DB) (*Quote, error) { |
|
| 84 | + | n, err := countQuotes(db) |
|
| 85 | + | if err != nil { |
|
| 86 | + | return nil, err |
|
| 87 | + | } |
|
| 88 | + | if n == 0 { |
|
| 89 | + | return nil, nil |
|
| 90 | + | } |
|
| 91 | + | offset := int(time.Now().UTC().Unix()/86400) % n |
|
| 92 | + | return scanQuote(db.QueryRow(`SELECT `+selectCols+` FROM quotes ORDER BY id LIMIT 1 OFFSET ?`, offset)) |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | func searchQuotes(db *sql.DB, q string) ([]Quote, error) { |
|
| 96 | + | term := strings.TrimSpace(q) |
|
| 97 | + | if term == "" { |
|
| 98 | + | return nil, nil |
|
| 99 | + | } |
|
| 100 | + | pattern := "%" + strings.ToLower(term) + "%" |
|
| 101 | + | rows, err := db.Query( |
|
| 102 | + | `SELECT `+selectCols+` FROM quotes |
|
| 103 | + | WHERE LOWER(text) LIKE ? OR LOWER(author) LIKE ? OR LOWER(IFNULL(source,'')) LIKE ? |
|
| 104 | + | ORDER BY added_at DESC, id DESC LIMIT 50`, |
|
| 105 | + | pattern, pattern, pattern, |
|
| 106 | + | ) |
|
| 107 | + | if err != nil { |
|
| 108 | + | return nil, err |
|
| 109 | + | } |
|
| 110 | + | defer rows.Close() |
|
| 111 | + | var out []Quote |
|
| 112 | + | for rows.Next() { |
|
| 113 | + | q, err := scanQuote(rows) |
|
| 114 | + | if err != nil { |
|
| 115 | + | return nil, err |
|
| 116 | + | } |
|
| 117 | + | out = append(out, *q) |
|
| 118 | + | } |
|
| 119 | + | return out, rows.Err() |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | // insertQuote inserts a quote with a freshly generated short id. source may be |
|
| 123 | + | // empty, in which case it is stored as NULL. |
|
| 124 | + | func insertQuote(db *sql.DB, text, author, source string) (int64, error) { |
|
| 125 | + | shortID, err := auth.GenerateShortID(10) |
|
| 126 | + | if err != nil { |
|
| 127 | + | return 0, err |
|
| 128 | + | } |
|
| 129 | + | now := time.Now().UTC().Unix() |
|
| 130 | + | var src any |
|
| 131 | + | if strings.TrimSpace(source) != "" { |
|
| 132 | + | src = source |
|
| 133 | + | } |
|
| 134 | + | res, err := db.Exec( |
|
| 135 | + | `INSERT INTO quotes (short_id, text, author, source, added_at, updated_at) |
|
| 136 | + | VALUES (?, ?, ?, ?, ?, ?)`, |
|
| 137 | + | shortID, text, author, src, now, now, |
|
| 138 | + | ) |
|
| 139 | + | if err != nil { |
|
| 140 | + | return 0, err |
|
| 141 | + | } |
|
| 142 | + | return res.LastInsertId() |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | func deleteQuoteByShortID(db *sql.DB, shortID string) error { |
|
| 146 | + | _, err := db.Exec(`DELETE FROM quotes WHERE short_id = ?`, shortID) |
|
| 147 | + | return err |
|
| 148 | + | } |
| 1 | + | services: |
|
| 2 | + | app: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/quotes/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | + | environment: |
|
| 9 | + | - HOST=0.0.0.0 |
|
| 10 | + | - PORT=${PORT:-3000} |
|
| 11 | + | - QUOTES_DB_PATH=/data/quotes.sqlite |
|
| 12 | + | - QUOTES_PASSWORD=${QUOTES_PASSWORD:-changeme} |
|
| 13 | + | - QUOTES_API_KEY=${QUOTES_API_KEY:-} |
|
| 14 | + | - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}} |
|
| 15 | + | - COOKIE_SECURE=${COOKIE_SECURE:-false} |
|
| 16 | + | volumes: |
|
| 17 | + | - quotes-data:/data |
|
| 18 | + | restart: unless-stopped |
|
| 19 | + | ||
| 20 | + | volumes: |
|
| 21 | + | quotes-data: |
| 1 | + | module github.com/stevedylandev/andromeda/apps/quotes |
|
| 2 | + | ||
| 3 | + | go 1.24.4 |
|
| 4 | + | ||
| 5 | + | require ( |
|
| 6 | + | github.com/stevedylandev/andromeda/pkg/auth v0.0.0 |
|
| 7 | + | github.com/stevedylandev/andromeda/pkg/config v0.0.0 |
|
| 8 | + | github.com/stevedylandev/andromeda/pkg/darkmatter v0.0.0 |
|
| 9 | + | github.com/stevedylandev/andromeda/pkg/sqlite v0.0.0 |
|
| 10 | + | github.com/stevedylandev/andromeda/pkg/web v0.0.0 |
|
| 11 | + | ) |
|
| 12 | + | ||
| 13 | + | require ( |
|
| 14 | + | github.com/dustin/go-humanize v1.0.1 // indirect |
|
| 15 | + | github.com/google/uuid v1.6.0 // indirect |
|
| 16 | + | github.com/mattn/go-isatty v0.0.20 // indirect |
|
| 17 | + | github.com/ncruces/go-strftime v0.1.9 // indirect |
|
| 18 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
|
| 19 | + | golang.org/x/crypto v0.39.0 // indirect |
|
| 20 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect |
|
| 21 | + | golang.org/x/sys v0.33.0 // indirect |
|
| 22 | + | modernc.org/libc v1.65.7 // indirect |
|
| 23 | + | modernc.org/mathutil v1.7.1 // indirect |
|
| 24 | + | modernc.org/memory v1.11.0 // indirect |
|
| 25 | + | modernc.org/sqlite v1.37.1 // indirect |
|
| 26 | + | ) |
|
| 27 | + | ||
| 28 | + | replace ( |
|
| 29 | + | github.com/stevedylandev/andromeda/pkg/auth => ../../pkg/auth |
|
| 30 | + | github.com/stevedylandev/andromeda/pkg/config => ../../pkg/config |
|
| 31 | + | github.com/stevedylandev/andromeda/pkg/darkmatter => ../../pkg/darkmatter |
|
| 32 | + | github.com/stevedylandev/andromeda/pkg/sqlite => ../../pkg/sqlite |
|
| 33 | + | github.com/stevedylandev/andromeda/pkg/web => ../../pkg/web |
|
| 34 | + | ) |
| 1 | + | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
|
| 2 | + | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
|
| 3 | + | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
|
| 4 | + | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
|
| 5 | + | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
|
| 6 | + | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
|
| 7 | + | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
|
| 8 | + | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
|
| 9 | + | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= |
|
| 10 | + | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
|
| 11 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
|
| 12 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
|
| 13 | + | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= |
|
| 14 | + | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= |
|
| 15 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= |
|
| 16 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= |
|
| 17 | + | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= |
|
| 18 | + | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= |
|
| 19 | + | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= |
|
| 20 | + | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= |
|
| 21 | + | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 22 | + | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= |
|
| 23 | + | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= |
|
| 24 | + | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= |
|
| 25 | + | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= |
|
| 26 | + | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= |
|
| 27 | + | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= |
|
| 28 | + | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= |
|
| 29 | + | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= |
|
| 30 | + | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= |
|
| 31 | + | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= |
|
| 32 | + | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
|
| 33 | + | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
|
| 34 | + | modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= |
|
| 35 | + | modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= |
|
| 36 | + | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
|
| 37 | + | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
|
| 38 | + | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
|
| 39 | + | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
|
| 40 | + | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= |
|
| 41 | + | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
|
| 42 | + | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
|
| 43 | + | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
|
| 44 | + | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= |
|
| 45 | + | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= |
|
| 46 | + | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
|
| 47 | + | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
|
| 48 | + | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
|
| 49 | + | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "net/http" |
|
| 5 | + | "strconv" |
|
| 6 | + | ||
| 7 | + | "github.com/stevedylandev/andromeda/pkg/web" |
|
| 8 | + | ) |
|
| 9 | + | ||
| 10 | + | func (a *App) apiListQuotes(w http.ResponseWriter, r *http.Request) { |
|
| 11 | + | limit := 100 |
|
| 12 | + | if v := r.URL.Query().Get("limit"); v != "" { |
|
| 13 | + | if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { |
|
| 14 | + | limit = n |
|
| 15 | + | } |
|
| 16 | + | } |
|
| 17 | + | quotes, err := listQuotes(a.DB, limit) |
|
| 18 | + | if err != nil { |
|
| 19 | + | a.Log.Error("list quotes", "err", err) |
|
| 20 | + | w.WriteHeader(http.StatusInternalServerError) |
|
| 21 | + | return |
|
| 22 | + | } |
|
| 23 | + | if quotes == nil { |
|
| 24 | + | quotes = []Quote{} |
|
| 25 | + | } |
|
| 26 | + | web.WriteJSON(w, http.StatusOK, quotes) |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | func (a *App) apiQuoteOfTheDay(w http.ResponseWriter, r *http.Request) { |
|
| 30 | + | q, err := quoteOfTheDay(a.DB) |
|
| 31 | + | if err != nil { |
|
| 32 | + | a.Log.Error("quote of the day", "err", err) |
|
| 33 | + | w.WriteHeader(http.StatusInternalServerError) |
|
| 34 | + | return |
|
| 35 | + | } |
|
| 36 | + | if q == nil { |
|
| 37 | + | web.WriteError(w, http.StatusNotFound, "no quotes") |
|
| 38 | + | return |
|
| 39 | + | } |
|
| 40 | + | web.WriteJSON(w, http.StatusOK, q) |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | func (a *App) apiGetQuote(w http.ResponseWriter, r *http.Request) { |
|
| 44 | + | q, err := getQuoteByShortID(a.DB, r.PathValue("short_id")) |
|
| 45 | + | if err != nil { |
|
| 46 | + | a.Log.Error("get quote", "err", err) |
|
| 47 | + | w.WriteHeader(http.StatusInternalServerError) |
|
| 48 | + | return |
|
| 49 | + | } |
|
| 50 | + | if q == nil { |
|
| 51 | + | web.WriteError(w, http.StatusNotFound, "not found") |
|
| 52 | + | return |
|
| 53 | + | } |
|
| 54 | + | web.WriteJSON(w, http.StatusOK, q) |
|
| 55 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "net/http" |
|
| 5 | + | "strings" |
|
| 6 | + | ||
| 7 | + | "github.com/stevedylandev/andromeda/pkg/auth" |
|
| 8 | + | "github.com/stevedylandev/andromeda/pkg/web" |
|
| 9 | + | ) |
|
| 10 | + | ||
| 11 | + | func quoteToView(q Quote) quoteView { |
|
| 12 | + | v := quoteView{Text: q.Text, Author: q.Author} |
|
| 13 | + | if q.Source != nil { |
|
| 14 | + | v.Source = *q.Source |
|
| 15 | + | } |
|
| 16 | + | return v |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | func quoteToRow(q Quote) adminQuoteRow { |
|
| 20 | + | r := adminQuoteRow{ShortID: q.ShortID, Text: q.Text, Author: q.Author} |
|
| 21 | + | if q.Source != nil { |
|
| 22 | + | r.Source = *q.Source |
|
| 23 | + | } |
|
| 24 | + | return r |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) { |
|
| 28 | + | data := indexPageData{BaseURL: a.BaseURL} |
|
| 29 | + | if q, err := quoteOfTheDay(a.DB); err != nil { |
|
| 30 | + | a.Log.Error("quote of the day", "err", err) |
|
| 31 | + | } else if q != nil { |
|
| 32 | + | v := quoteToView(*q) |
|
| 33 | + | data.Quote = &v |
|
| 34 | + | } |
|
| 35 | + | web.Render(a.Templates, w, "index.html", data, a.Log) |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) { |
|
| 39 | + | web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log) |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) { |
|
| 43 | + | if a.AdminPassword == "" { |
|
| 44 | + | web.RedirectWithError(w, r, "/admin/login", "No admin password configured") |
|
| 45 | + | return |
|
| 46 | + | } |
|
| 47 | + | if err := r.ParseForm(); err != nil { |
|
| 48 | + | web.RedirectWithError(w, r, "/admin/login", "Bad request") |
|
| 49 | + | return |
|
| 50 | + | } |
|
| 51 | + | if !auth.VerifyPassword(r.FormValue("password"), a.AdminPassword) { |
|
| 52 | + | web.RedirectWithError(w, r, "/admin/login", "Invalid password") |
|
| 53 | + | return |
|
| 54 | + | } |
|
| 55 | + | token, err := a.Sessions.Create() |
|
| 56 | + | if err != nil { |
|
| 57 | + | a.Log.Error("create session failed", "err", err) |
|
| 58 | + | web.RedirectWithError(w, r, "/admin/login", "Session error") |
|
| 59 | + | return |
|
| 60 | + | } |
|
| 61 | + | a.Sessions.PruneExpired() |
|
| 62 | + | http.SetCookie(w, a.Sessions.SessionCookie(token)) |
|
| 63 | + | http.Redirect(w, r, "/admin", http.StatusSeeOther) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) { |
|
| 67 | + | if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" { |
|
| 68 | + | a.Sessions.Delete(c.Value) |
|
| 69 | + | } |
|
| 70 | + | http.SetCookie(w, a.Sessions.ClearCookie()) |
|
| 71 | + | http.Redirect(w, r, "/admin/login", http.StatusSeeOther) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) { |
|
| 75 | + | total, _ := countQuotes(a.DB) |
|
| 76 | + | ||
| 77 | + | query := r.URL.Query().Get("q") |
|
| 78 | + | searched := strings.TrimSpace(query) != "" |
|
| 79 | + | ||
| 80 | + | var found []Quote |
|
| 81 | + | if searched { |
|
| 82 | + | found, _ = searchQuotes(a.DB, query) |
|
| 83 | + | } else { |
|
| 84 | + | found, _ = listQuotes(a.DB, 50) |
|
| 85 | + | } |
|
| 86 | + | rows := make([]adminQuoteRow, 0, len(found)) |
|
| 87 | + | for _, q := range found { |
|
| 88 | + | rows = append(rows, quoteToRow(q)) |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | web.Render(a.Templates, w, "admin.html", adminPageData{ |
|
| 92 | + | Success: r.URL.Query().Get("success"), |
|
| 93 | + | Error: r.URL.Query().Get("error"), |
|
| 94 | + | Total: total, |
|
| 95 | + | Quotes: rows, |
|
| 96 | + | Query: query, |
|
| 97 | + | Searched: searched, |
|
| 98 | + | }, a.Log) |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | func (a *App) adminAddQuote(w http.ResponseWriter, r *http.Request) { |
|
| 102 | + | if err := r.ParseForm(); err != nil { |
|
| 103 | + | web.RedirectWithError(w, r, "/admin", "Bad request") |
|
| 104 | + | return |
|
| 105 | + | } |
|
| 106 | + | text := strings.TrimSpace(r.FormValue("text")) |
|
| 107 | + | author := strings.TrimSpace(r.FormValue("author")) |
|
| 108 | + | source := strings.TrimSpace(r.FormValue("source")) |
|
| 109 | + | if text == "" || author == "" { |
|
| 110 | + | web.RedirectWithError(w, r, "/admin", "Quote and author are required") |
|
| 111 | + | return |
|
| 112 | + | } |
|
| 113 | + | if _, err := insertQuote(a.DB, text, author, source); err != nil { |
|
| 114 | + | a.Log.Error("insert quote", "err", err) |
|
| 115 | + | web.RedirectWithError(w, r, "/admin", "Failed to add quote") |
|
| 116 | + | return |
|
| 117 | + | } |
|
| 118 | + | web.RedirectWithSuccess(w, r, "/admin", "Quote added") |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | func (a *App) adminDeleteQuote(w http.ResponseWriter, r *http.Request) { |
|
| 122 | + | if err := deleteQuoteByShortID(a.DB, r.PathValue("short_id")); err != nil { |
|
| 123 | + | a.Log.Error("delete quote", "err", err) |
|
| 124 | + | web.RedirectWithError(w, r, "/admin", "Failed to remove quote") |
|
| 125 | + | return |
|
| 126 | + | } |
|
| 127 | + | web.RedirectWithSuccess(w, r, "/admin", "Quote removed") |
|
| 128 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "html/template" |
|
| 5 | + | "log" |
|
| 6 | + | "log/slog" |
|
| 7 | + | "net/http" |
|
| 8 | + | "os" |
|
| 9 | + | ||
| 10 | + | "github.com/stevedylandev/andromeda/pkg/auth" |
|
| 11 | + | "github.com/stevedylandev/andromeda/pkg/config" |
|
| 12 | + | "github.com/stevedylandev/andromeda/pkg/sqlite" |
|
| 13 | + | ) |
|
| 14 | + | ||
| 15 | + | func main() { |
|
| 16 | + | config.LoadDotEnv(".env") |
|
| 17 | + | logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) |
|
| 18 | + | ||
| 19 | + | dbPath := config.Getenv("QUOTES_DB_PATH", "quotes.sqlite") |
|
| 20 | + | ||
| 21 | + | // Seed subcommand: `quotes seed <csv>` imports classic-literature quotes |
|
| 22 | + | // from a Goodreads-style CSV and exits. Not part of normal boot. |
|
| 23 | + | if len(os.Args) > 1 && os.Args[1] == "seed" { |
|
| 24 | + | csvPath := "quotes.csv" |
|
| 25 | + | if len(os.Args) > 2 { |
|
| 26 | + | csvPath = os.Args[2] |
|
| 27 | + | } |
|
| 28 | + | if err := runSeed(logger, dbPath, csvPath); err != nil { |
|
| 29 | + | log.Fatal(err) |
|
| 30 | + | } |
|
| 31 | + | return |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | db, err := sqlite.Open(dbPath, quotesSchema) |
|
| 35 | + | if err != nil { |
|
| 36 | + | log.Fatal(err) |
|
| 37 | + | } |
|
| 38 | + | defer db.Close() |
|
| 39 | + | ||
| 40 | + | sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)} |
|
| 41 | + | if err := sessions.EnsureSchema(); err != nil { |
|
| 42 | + | log.Fatal(err) |
|
| 43 | + | } |
|
| 44 | + | sessions.PruneExpired() |
|
| 45 | + | ||
| 46 | + | adminPassword := config.Getenv("QUOTES_PASSWORD", "") |
|
| 47 | + | if adminPassword == "" { |
|
| 48 | + | logger.Warn("QUOTES_PASSWORD not set; admin login is disabled") |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | tmpl := template.Must(template.ParseFS(appFS, "templates/*.html")) |
|
| 52 | + | app := &App{ |
|
| 53 | + | DB: db, |
|
| 54 | + | Log: logger, |
|
| 55 | + | Templates: tmpl, |
|
| 56 | + | Sessions: sessions, |
|
| 57 | + | AdminPassword: adminPassword, |
|
| 58 | + | APIKey: config.Getenv("QUOTES_API_KEY", ""), |
|
| 59 | + | CookieSecure: sessions.CookieSecure, |
|
| 60 | + | BaseURL: config.Getenv("BASE_URL", "http://localhost:3000"), |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000") |
|
| 64 | + | logger.Info("quotes server running", "addr", addr) |
|
| 65 | + | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 66 | + | log.Fatal(err) |
|
| 67 | + | } |
|
| 68 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "net/http" |
|
| 5 | + | ||
| 6 | + | "github.com/stevedylandev/andromeda/pkg/darkmatter" |
|
| 7 | + | "github.com/stevedylandev/andromeda/pkg/web" |
|
| 8 | + | ) |
|
| 9 | + | ||
| 10 | + | func (a *App) routes() *http.ServeMux { |
|
| 11 | + | mux := http.NewServeMux() |
|
| 12 | + | ||
| 13 | + | requireSession := func(next http.HandlerFunc) http.HandlerFunc { |
|
| 14 | + | return a.Sessions.RequireSession("/admin/login", next) |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | mux.HandleFunc("GET /", a.indexHandler) |
|
| 18 | + | mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static")) |
|
| 19 | + | darkmatter.Mount(mux, "/assets") |
|
| 20 | + | ||
| 21 | + | mux.HandleFunc("GET /admin/login", a.loginGetHandler) |
|
| 22 | + | mux.HandleFunc("POST /admin/login", a.loginPostHandler) |
|
| 23 | + | mux.HandleFunc("GET /admin/logout", a.logoutHandler) |
|
| 24 | + | mux.HandleFunc("GET /admin", requireSession(a.adminHandler)) |
|
| 25 | + | mux.HandleFunc("POST /admin/add", requireSession(a.adminAddQuote)) |
|
| 26 | + | mux.HandleFunc("POST /admin/quotes/{short_id}/delete", requireSession(a.adminDeleteQuote)) |
|
| 27 | + | ||
| 28 | + | mux.HandleFunc("GET /api/quotes", a.apiListQuotes) |
|
| 29 | + | mux.HandleFunc("GET /api/quotes/today", a.apiQuoteOfTheDay) |
|
| 30 | + | mux.HandleFunc("GET /api/quotes/{short_id}", a.apiGetQuote) |
|
| 31 | + | ||
| 32 | + | return mux |
|
| 33 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | _ "embed" |
|
| 5 | + | "encoding/csv" |
|
| 6 | + | "errors" |
|
| 7 | + | "fmt" |
|
| 8 | + | "io" |
|
| 9 | + | "log/slog" |
|
| 10 | + | "os" |
|
| 11 | + | "strings" |
|
| 12 | + | "time" |
|
| 13 | + | ||
| 14 | + | "github.com/stevedylandev/andromeda/pkg/auth" |
|
| 15 | + | "github.com/stevedylandev/andromeda/pkg/sqlite" |
|
| 16 | + | ) |
|
| 17 | + | ||
| 18 | + | const classicListPath = "classic_authors.txt" |
|
| 19 | + | ||
| 20 | + | // embeddedClassicList is baked into the binary so the seed command works inside |
|
| 21 | + | // a container (or anywhere the txt file is absent) without needing the file on |
|
| 22 | + | // disk. An on-disk classic_authors.txt still takes precedence, so local edits |
|
| 23 | + | // apply without a rebuild. |
|
| 24 | + | // |
|
| 25 | + | //go:embed classic_authors.txt |
|
| 26 | + | var embeddedClassicList string |
|
| 27 | + | ||
| 28 | + | func parseClassicList(data string) []string { |
|
| 29 | + | var out []string |
|
| 30 | + | for _, line := range strings.Split(data, "\n") { |
|
| 31 | + | line = strings.TrimSpace(line) |
|
| 32 | + | if line == "" || strings.HasPrefix(line, "#") { |
|
| 33 | + | continue |
|
| 34 | + | } |
|
| 35 | + | out = append(out, line) |
|
| 36 | + | } |
|
| 37 | + | return out |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | // loadClassicList reads the match list from disk, falling back to the embedded |
|
| 41 | + | // copy when the file is absent. Skips blank lines and # comments. |
|
| 42 | + | func loadClassicList(path string) ([]string, bool, error) { |
|
| 43 | + | data, err := os.ReadFile(path) |
|
| 44 | + | if errors.Is(err, os.ErrNotExist) { |
|
| 45 | + | return parseClassicList(embeddedClassicList), true, nil |
|
| 46 | + | } |
|
| 47 | + | if err != nil { |
|
| 48 | + | return nil, false, err |
|
| 49 | + | } |
|
| 50 | + | return parseClassicList(string(data)), false, nil |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | // matchesClassic reports whether the author column contains any list entry as a |
|
| 54 | + | // case-sensitive substring. |
|
| 55 | + | func matchesClassic(author string, list []string) bool { |
|
| 56 | + | for _, name := range list { |
|
| 57 | + | if strings.Contains(author, name) { |
|
| 58 | + | return true |
|
| 59 | + | } |
|
| 60 | + | } |
|
| 61 | + | return false |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | // splitAttribution splits a CSV author field of the form "Author, Book Title" |
|
| 65 | + | // at the first comma into author and source. No comma -> empty source. |
|
| 66 | + | func splitAttribution(field string) (author, source string) { |
|
| 67 | + | if i := strings.Index(field, ","); i >= 0 { |
|
| 68 | + | return strings.TrimSpace(field[:i]), strings.TrimSpace(field[i+1:]) |
|
| 69 | + | } |
|
| 70 | + | return strings.TrimSpace(field), "" |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | func listSource(embedded bool) string { |
|
| 74 | + | if embedded { |
|
| 75 | + | return "embedded" |
|
| 76 | + | } |
|
| 77 | + | return classicListPath |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | func dedupKey(text, author string) string { |
|
| 81 | + | return text + "\x00" + author |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | // runSeed imports classic-literature quotes from csvPath into the database at |
|
| 85 | + | // dbPath. It is idempotent: quotes already present (matched on text+author) are |
|
| 86 | + | // skipped, so re-running after editing classic_authors.txt only adds new rows. |
|
| 87 | + | func runSeed(logger *slog.Logger, dbPath, csvPath string) error { |
|
| 88 | + | list, embedded, err := loadClassicList(classicListPath) |
|
| 89 | + | if err != nil { |
|
| 90 | + | return fmt.Errorf("read %s: %w", classicListPath, err) |
|
| 91 | + | } |
|
| 92 | + | logger.Info("loaded classic list", "entries", len(list), "source", listSource(embedded)) |
|
| 93 | + | ||
| 94 | + | db, err := sqlite.Open(dbPath, quotesSchema) |
|
| 95 | + | if err != nil { |
|
| 96 | + | return err |
|
| 97 | + | } |
|
| 98 | + | defer db.Close() |
|
| 99 | + | ||
| 100 | + | // Preload existing (text, author) pairs so re-seeds stay idempotent. |
|
| 101 | + | seen := map[string]struct{}{} |
|
| 102 | + | rows, err := db.Query(`SELECT text, author FROM quotes`) |
|
| 103 | + | if err != nil { |
|
| 104 | + | return err |
|
| 105 | + | } |
|
| 106 | + | for rows.Next() { |
|
| 107 | + | var t, a string |
|
| 108 | + | if err := rows.Scan(&t, &a); err != nil { |
|
| 109 | + | rows.Close() |
|
| 110 | + | return err |
|
| 111 | + | } |
|
| 112 | + | seen[dedupKey(t, a)] = struct{}{} |
|
| 113 | + | } |
|
| 114 | + | rows.Close() |
|
| 115 | + | ||
| 116 | + | f, err := os.Open(csvPath) |
|
| 117 | + | if err != nil { |
|
| 118 | + | return err |
|
| 119 | + | } |
|
| 120 | + | defer f.Close() |
|
| 121 | + | ||
| 122 | + | reader := csv.NewReader(f) |
|
| 123 | + | reader.FieldsPerRecord = -1 |
|
| 124 | + | if _, err := reader.Read(); err != nil { // skip header |
|
| 125 | + | return err |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | tx, err := db.Begin() |
|
| 129 | + | if err != nil { |
|
| 130 | + | return err |
|
| 131 | + | } |
|
| 132 | + | stmt, err := tx.Prepare( |
|
| 133 | + | `INSERT INTO quotes (short_id, text, author, source, added_at, updated_at) |
|
| 134 | + | VALUES (?, ?, ?, ?, ?, ?)`, |
|
| 135 | + | ) |
|
| 136 | + | if err != nil { |
|
| 137 | + | tx.Rollback() |
|
| 138 | + | return err |
|
| 139 | + | } |
|
| 140 | + | defer stmt.Close() |
|
| 141 | + | ||
| 142 | + | now := time.Now().UTC().Unix() |
|
| 143 | + | var scanned, inserted, skipped int |
|
| 144 | + | for { |
|
| 145 | + | rec, err := reader.Read() |
|
| 146 | + | if err == io.EOF { |
|
| 147 | + | break |
|
| 148 | + | } |
|
| 149 | + | if err != nil { |
|
| 150 | + | logger.Warn("skipping malformed row", "err", err) |
|
| 151 | + | continue |
|
| 152 | + | } |
|
| 153 | + | if len(rec) < 2 { |
|
| 154 | + | continue |
|
| 155 | + | } |
|
| 156 | + | scanned++ |
|
| 157 | + | text := strings.TrimSpace(rec[0]) |
|
| 158 | + | rawAuthor := rec[1] |
|
| 159 | + | if text == "" || !matchesClassic(rawAuthor, list) { |
|
| 160 | + | continue |
|
| 161 | + | } |
|
| 162 | + | author, source := splitAttribution(rawAuthor) |
|
| 163 | + | if author == "" { |
|
| 164 | + | continue |
|
| 165 | + | } |
|
| 166 | + | key := dedupKey(text, author) |
|
| 167 | + | if _, ok := seen[key]; ok { |
|
| 168 | + | skipped++ |
|
| 169 | + | continue |
|
| 170 | + | } |
|
| 171 | + | shortID, err := auth.GenerateShortID(10) |
|
| 172 | + | if err != nil { |
|
| 173 | + | tx.Rollback() |
|
| 174 | + | return err |
|
| 175 | + | } |
|
| 176 | + | var src any |
|
| 177 | + | if source != "" { |
|
| 178 | + | src = source |
|
| 179 | + | } |
|
| 180 | + | if _, err := stmt.Exec(shortID, text, author, src, now, now); err != nil { |
|
| 181 | + | tx.Rollback() |
|
| 182 | + | return err |
|
| 183 | + | } |
|
| 184 | + | seen[key] = struct{}{} |
|
| 185 | + | inserted++ |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | if err := tx.Commit(); err != nil { |
|
| 189 | + | return err |
|
| 190 | + | } |
|
| 191 | + | total, _ := countQuotes(db) |
|
| 192 | + | logger.Info("seed complete", "scanned", scanned, "inserted", inserted, "skipped_duplicates", skipped, "total_in_db", total) |
|
| 193 | + | return nil |
|
| 194 | + | } |
| 1 | + | { |
|
| 2 | + | "name": "Quotes", |
|
| 3 | + | "short_name": "Quotes", |
|
| 4 | + | "icons": [], |
|
| 5 | + | "theme_color": "#121113", |
|
| 6 | + | "background_color": "#121113", |
|
| 7 | + | "display": "standalone" |
|
| 8 | + | } |
| 1 | + | /* App-local styles for quotes. The shared design system (palette, typography, |
|
| 2 | + | forms, admin lists) comes from /assets/darkmatter.css. Only the minimal |
|
| 3 | + | centered landing "hero" is added here, since darkmatter's body is a |
|
| 4 | + | left-aligned 700px column and does not vertically center. */ |
|
| 5 | + | ||
| 6 | + | .quote-hero { |
|
| 7 | + | width: 100%; |
|
| 8 | + | min-height: 100vh; |
|
| 9 | + | display: flex; |
|
| 10 | + | flex-direction: column; |
|
| 11 | + | justify-content: center; |
|
| 12 | + | align-items: center; |
|
| 13 | + | text-align: center; |
|
| 14 | + | gap: 1.5rem; |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | .quote-hero .quote-text { |
|
| 18 | + | margin: 0; |
|
| 19 | + | padding: 0; |
|
| 20 | + | border: 0; |
|
| 21 | + | font-size: 1.5rem; |
|
| 22 | + | line-height: 1.6; |
|
| 23 | + | max-width: 38rem; |
|
| 24 | + | color: #ffffff; |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | .quote-hero .attribution { |
|
| 28 | + | margin: 0; |
|
| 29 | + | opacity: 0.5; |
|
| 30 | + | font-size: 0.875rem; |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | .quote-hero cite { |
|
| 34 | + | font-style: italic; |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | .admin-list-meta cite { |
|
| 38 | + | font-style: italic; |
|
| 39 | + | } |
| 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="manifest" href="/static/site.webmanifest" /> |
|
| 10 | + | <title>Quotes | Admin</title> |
|
| 11 | + | </head> |
|
| 12 | + | <body> |
|
| 13 | + | <div class="header"> |
|
| 14 | + | <a href="/" class="logo">QUOTES</a> |
|
| 15 | + | <nav class="links"> |
|
| 16 | + | <a href="/admin/logout">logout</a> |
|
| 17 | + | </nav> |
|
| 18 | + | </div> |
|
| 19 | + | ||
| 20 | + | {{if .Success}}<p class="success">{{.Success}}</p>{{end}} |
|
| 21 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 22 | + | ||
| 23 | + | <form class="form" method="POST" action="/admin/add"> |
|
| 24 | + | <div class="form-field"> |
|
| 25 | + | <label for="text">Quote</label> |
|
| 26 | + | <textarea id="text" name="text" rows="3" placeholder="the quote" required></textarea> |
|
| 27 | + | </div> |
|
| 28 | + | <div class="form-row"> |
|
| 29 | + | <div class="form-field"> |
|
| 30 | + | <label for="author">Author</label> |
|
| 31 | + | <input type="text" id="author" name="author" placeholder="author" required /> |
|
| 32 | + | </div> |
|
| 33 | + | <div class="form-field"> |
|
| 34 | + | <label for="source">Source</label> |
|
| 35 | + | <input type="text" id="source" name="source" placeholder="book / work (optional)" /> |
|
| 36 | + | </div> |
|
| 37 | + | </div> |
|
| 38 | + | <div class="form-actions"> |
|
| 39 | + | <button type="submit">Add quote</button> |
|
| 40 | + | </div> |
|
| 41 | + | </form> |
|
| 42 | + | ||
| 43 | + | <div class="admin-toolbar"> |
|
| 44 | + | <h2>Quotes ({{.Total}})</h2> |
|
| 45 | + | <form method="GET" action="/admin" class="form-row"> |
|
| 46 | + | <div class="form-field"> |
|
| 47 | + | <input type="text" name="q" placeholder="search text, author, source" value="{{.Query}}" /> |
|
| 48 | + | </div> |
|
| 49 | + | <button type="submit">Search</button> |
|
| 50 | + | {{if .Searched}}<a href="/admin" class="link-button">clear</a>{{end}} |
|
| 51 | + | </form> |
|
| 52 | + | </div> |
|
| 53 | + | ||
| 54 | + | {{if not .Quotes}} |
|
| 55 | + | <p class="empty"> |
|
| 56 | + | {{if .Searched}}No matches.{{else}}No quotes yet. Add one above or seed from the CSV.{{end}} |
|
| 57 | + | </p> |
|
| 58 | + | {{else}} |
|
| 59 | + | <ul class="admin-list"> |
|
| 60 | + | {{range .Quotes}} |
|
| 61 | + | <li class="admin-list-item"> |
|
| 62 | + | <div class="admin-list-info"> |
|
| 63 | + | <span class="admin-list-title">{{.Text}}</span> |
|
| 64 | + | <span class="admin-list-meta">— {{.Author}}{{if .Source}}, <cite>{{.Source}}</cite>{{end}}</span> |
|
| 65 | + | </div> |
|
| 66 | + | <div class="admin-list-actions"> |
|
| 67 | + | <form method="POST" action="/admin/quotes/{{.ShortID}}/delete" class="inline-form"> |
|
| 68 | + | <button type="submit" class="link-button danger">delete</button> |
|
| 69 | + | </form> |
|
| 70 | + | </div> |
|
| 71 | + | </li> |
|
| 72 | + | {{end}} |
|
| 73 | + | </ul> |
|
| 74 | + | {{if not .Searched}} |
|
| 75 | + | <p class="empty">Showing the 50 most recent. Use search to find older quotes.</p> |
|
| 76 | + | {{end}} |
|
| 77 | + | {{end}} |
|
| 78 | + | </body> |
|
| 79 | + | </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="manifest" href="/static/site.webmanifest" /> |
|
| 10 | + | <title>Quotes</title> |
|
| 11 | + | <meta name="description" content="A quote a day from classic literature" /> |
|
| 12 | + | <meta property="og:url" content="{{.BaseURL}}" /> |
|
| 13 | + | <meta property="og:type" content="website" /> |
|
| 14 | + | <meta property="og:title" content="Quotes" /> |
|
| 15 | + | <meta property="og:description" content="A quote a day from classic literature" /> |
|
| 16 | + | <meta name="twitter:card" content="summary_large_image" /> |
|
| 17 | + | <meta property="twitter:url" content="{{.BaseURL}}" /> |
|
| 18 | + | <meta name="twitter:title" content="Quotes" /> |
|
| 19 | + | <meta name="twitter:description" content="A quote a day from classic literature" /> |
|
| 20 | + | </head> |
|
| 21 | + | <body> |
|
| 22 | + | <main class="quote-hero"> |
|
| 23 | + | {{if .Quote}} |
|
| 24 | + | <blockquote class="quote-text">{{.Quote.Text}}</blockquote> |
|
| 25 | + | <p class="attribution"> |
|
| 26 | + | — {{.Quote.Author}}{{if .Quote.Source}}, <cite>{{.Quote.Source}}</cite>{{end}} |
|
| 27 | + | </p> |
|
| 28 | + | {{else}} |
|
| 29 | + | <p class="attribution">No quotes yet.</p> |
|
| 30 | + | {{end}} |
|
| 31 | + | </main> |
|
| 32 | + | </body> |
|
| 33 | + | </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="manifest" href="/static/site.webmanifest" /> |
|
| 10 | + | <title>Quotes | Login</title> |
|
| 11 | + | </head> |
|
| 12 | + | <body> |
|
| 13 | + | <div class="header"> |
|
| 14 | + | <a href="/" class="logo">QUOTES</a> |
|
| 15 | + | </div> |
|
| 16 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 17 | + | <form class="form" method="POST" action="/admin/login"> |
|
| 18 | + | <div class="form-field"> |
|
| 19 | + | <label for="password">Password</label> |
|
| 20 | + | <input type="password" id="password" name="password" required autofocus /> |
|
| 21 | + | </div> |
|
| 22 | + | <div class="form-actions"> |
|
| 23 | + | <button type="submit">Login</button> |
|
| 24 | + | </div> |
|
| 25 | + | </form> |
|
| 26 | + | </body> |
|
| 27 | + | </html> |
| 105 | 105 | - blobs_data:/data |
|
| 106 | 106 | env_file: apps/blobs/.env |
|
| 107 | 107 | ||
| 108 | + | quotes: |
|
| 109 | + | image: ghcr.io/stevedylandev/andromeda/quotes:latest |
|
| 110 | + | restart: unless-stopped |
|
| 111 | + | ports: |
|
| 112 | + | - "4040:3000" |
|
| 113 | + | volumes: |
|
| 114 | + | - quotes_data:/data |
|
| 115 | + | env_file: apps/quotes/.env |
|
| 116 | + | ||
| 108 | 117 | backup: |
|
| 109 | 118 | image: ghcr.io/stevedylandev/andromeda/backup:latest |
|
| 110 | 119 | volumes: |
|
| 117 | 126 | - bookmarks_data:/data/bookmarks:ro |
|
| 118 | 127 | - easel_data:/data/easel:ro |
|
| 119 | 128 | - blobs_data:/data/blobs:ro |
|
| 129 | + | - quotes_data:/data/quotes:ro |
|
| 120 | 130 | env_file: apps/backup/.env |
|
| 121 | 131 | restart: unless-stopped |
|
| 122 | 132 | ||
| 151 | 161 | blobs_data: |
|
| 152 | 162 | external: true |
|
| 153 | 163 | name: blobs_blobs-data |
|
| 164 | + | quotes_data: |
|
| 165 | + | external: true |
|
| 166 | + | name: quotes_quotes-data |
|